mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 02:21:38 -10:00
Compare commits
332
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa0e2b01cd | ||
|
|
f663c83f98 | ||
|
|
4740691c04 | ||
|
|
04a26210e3 | ||
|
|
f37bcae92c | ||
|
|
04b53013c8 | ||
|
|
5070174aef | ||
|
|
5841fdc1e7 | ||
|
|
2093fb4d0d | ||
|
|
4e84f9e7fe | ||
|
|
6d45e61cdc | ||
|
|
960c03a4a1 | ||
|
|
7618cdca8e | ||
|
|
31c02b8f59 | ||
|
|
67c6dfbca9 | ||
|
|
7ef8abd367 | ||
|
|
63f20e8f30 | ||
|
|
f25fdf5828 | ||
|
|
87ef5ed8d9 | ||
|
|
31e1d6aedf | ||
|
|
cc4067a168 | ||
|
|
f7c9520898 | ||
|
|
417728114c | ||
|
|
1f2fd92867 | ||
|
|
b955f83e5b | ||
|
|
916dd0cebb | ||
|
|
b4977c3700 | ||
|
|
8cbfc508ae | ||
|
|
1843b792ff | ||
|
|
8b6513f833 | ||
|
|
3895a99900 | ||
|
|
70f3965c0a | ||
|
|
e51f313b02 | ||
|
|
339bda5ed2 | ||
|
|
0cc2963ccf | ||
|
|
1b27a37702 | ||
|
|
d4d833f199 | ||
|
|
1101314498 | ||
|
|
68b4c07179 | ||
|
|
395789e107 | ||
|
|
9223dc0e30 | ||
|
|
d9fe05af0d | ||
|
|
f02798aed5 | ||
|
|
900933aae2 | ||
|
|
2ef31a7618 | ||
|
|
cbf533a8ae | ||
|
|
ccc233474d | ||
|
|
1b16aaecbf | ||
|
|
b972efd9fa | ||
|
|
8a4ca5e7de | ||
|
|
2160d3e4d1 | ||
|
|
4536ec51ca | ||
|
|
5cc88b517b | ||
|
|
72d1238876 | ||
|
|
214469a4bf | ||
|
|
b2bfa61fb1 | ||
|
|
5f4dbb058b | ||
|
|
292dfe14e1 | ||
|
|
506624b951 | ||
|
|
e7fb465789 | ||
|
|
31af1ed8ad | ||
|
|
af3a8d235b | ||
|
|
3c564f0e2b | ||
|
|
45c4e0d744 | ||
|
|
802056c402 | ||
|
|
fc604a958a | ||
|
|
8f98a65be5 | ||
|
|
75dc9db341 | ||
|
|
eb161b9c99 | ||
|
|
7687b2a619 | ||
|
|
1d50e158b9 | ||
|
|
58b5d93f8f | ||
|
|
9951ef958d | ||
|
|
8cb5c02d4b | ||
|
|
b75ffd9554 | ||
|
|
c7552a32e5 | ||
|
|
ff03932fb0 | ||
|
|
6a007e8e02 | ||
|
|
a4b71ca8ba | ||
|
|
1386a7fd1f | ||
|
|
5f029bb260 | ||
|
|
23a2b65c29 | ||
|
|
62165d135d | ||
|
|
2732d737a1 | ||
|
|
a1807b93a1 | ||
|
|
53c0b0694b | ||
|
|
cc3e92cadd | ||
|
|
27e0787a23 | ||
|
|
f312c30454 | ||
|
|
a79db77ac1 | ||
|
|
5fde1275b5 | ||
|
|
7ebdfbc3ee | ||
|
|
9e4e43a69b | ||
|
|
78f286e657 | ||
|
|
71e8f7c8cd | ||
|
|
91429d906a | ||
|
|
846e34c2eb | ||
|
|
a084ad2f35 | ||
|
|
10588c9b4c | ||
|
|
8ad2bee66a | ||
|
|
3e8dbbc2df | ||
|
|
bbe1ec5372 | ||
|
|
1f6cf55e1c | ||
|
|
41a579d428 | ||
|
|
0b21fcbf60 | ||
|
|
fb9b1d3542 | ||
|
|
4d4aec1c7a | ||
|
|
a8a5843f59 | ||
|
|
63a2feba79 | ||
|
|
b228e6bef7 | ||
|
|
6a246dd81d | ||
|
|
4c49ec3523 | ||
|
|
2e6f3cd716 | ||
|
|
289e90d636 | ||
|
|
3275cd1602 | ||
|
|
a100957b30 | ||
|
|
3dcdbf8947 | ||
|
|
5da9065a8d | ||
|
|
a04de39fea | ||
|
|
76d95dbcc9 | ||
|
|
b9ad2da17e | ||
|
|
fab1e1a3e6 | ||
|
|
f6d8444698 | ||
|
|
b5a64cf86b | ||
|
|
0966aa0d3f | ||
|
|
9188926fff | ||
|
|
b920f8a432 | ||
|
|
16fb5cfed0 | ||
|
|
ef9f8e86d7 | ||
|
|
b91c2d4965 | ||
|
|
1cf4775de9 | ||
|
|
bdf670abdf | ||
|
|
327b811914 | ||
|
|
e1848e08a4 | ||
|
|
3661bfd7c2 | ||
|
|
a08205b8ec | ||
|
|
41fdaa3f0c | ||
|
|
2398317072 | ||
|
|
669e1a162a | ||
|
|
d645b72e8b | ||
|
|
3bc4e3035c | ||
|
|
15183261d9 | ||
|
|
adbc523e5c | ||
|
|
7f5b3462df | ||
|
|
c106b315ad | ||
|
|
1d9040c05a | ||
|
|
a35b729ccf | ||
|
|
27cded80d5 | ||
|
|
64e74bffd2 | ||
|
|
db26868e4f | ||
|
|
5cfbc7e3ea | ||
|
|
3f1f2fd3a8 | ||
|
|
ab7d9f4c50 | ||
|
|
4f1b40d9ca | ||
|
|
81a8d04a1f | ||
|
|
e1a6c5e363 | ||
|
|
89733192ca | ||
|
|
b973a2ed28 | ||
|
|
03a6255967 | ||
|
|
6539061751 | ||
|
|
7059358371 | ||
|
|
d05c54b48e | ||
|
|
9f9d226294 | ||
|
|
0a9ceaedb2 | ||
|
|
d64e5172b6 | ||
|
|
faa8141c2f | ||
|
|
2d5e7839c6 | ||
|
|
7a47e7a21f | ||
|
|
188817342b | ||
|
|
56c7214447 | ||
|
|
c1d32a0f93 | ||
|
|
861914b486 | ||
|
|
d1ca042ab8 | ||
|
|
1e66c75589 | ||
|
|
bd6bf2422b | ||
|
|
72879fc35b | ||
|
|
38b2b41791 | ||
|
|
5f40c762f6 | ||
|
|
46988764a5 | ||
|
|
2289b7a732 | ||
|
|
d68f241a0e | ||
|
|
176f5b3475 | ||
|
|
34d39e0da0 | ||
|
|
a45aa615ef | ||
|
|
72fe5b13ba | ||
|
|
c7ca421167 | ||
|
|
aaf5aa6fcf | ||
|
|
eaa027179b | ||
|
|
bbaa73ec43 | ||
|
|
d3c8eb00ea | ||
|
|
c1540bd026 | ||
|
|
8683a6b570 | ||
|
|
d88c0d9011 | ||
|
|
9f861b1624 | ||
|
|
bdf33dd2a3 | ||
|
|
016630efda | ||
|
|
3a953f4f9a | ||
|
|
91b743b549 | ||
|
|
59923087d5 | ||
|
|
763b6f6c8c | ||
|
|
88c287eb7e | ||
|
|
94f499bdbe | ||
|
|
f943e82857 | ||
|
|
6efab1f890 | ||
|
|
34b5ab04b0 | ||
|
|
a76ea00dde | ||
|
|
d7c7e06634 | ||
|
|
7f8a855d78 | ||
|
|
5f68b5da25 | ||
|
|
ca01155bce | ||
|
|
2740471e60 | ||
|
|
3f63aa1f5c | ||
|
|
a151569d72 | ||
|
|
bad4b0ef9a | ||
|
|
3a22ba32c2 | ||
|
|
5f61de11d9 | ||
|
|
1ef6e26dfa | ||
|
|
2c9aa0ac86 | ||
|
|
eedbb1717c | ||
|
|
fad8e4e555 | ||
|
|
dbbe0e6bf5 | ||
|
|
5a78eee6ca | ||
|
|
e11233f2dc | ||
|
|
941e45b1ba | ||
|
|
916064d1ac | ||
|
|
4ef09a8e0d | ||
|
|
d9cf8f497c | ||
|
|
a321aa6c73 | ||
|
|
2490c57d3e | ||
|
|
e624d71b21 | ||
|
|
a190dbbc0d | ||
|
|
f779b500ec | ||
|
|
4ed92f7176 | ||
|
|
6e8edf7ad2 | ||
|
|
deb5860d9e | ||
|
|
6dc2d97de4 | ||
|
|
920e317f53 | ||
|
|
9286b28e24 | ||
|
|
df4b2599b4 | ||
|
|
8aea9cb52b | ||
|
|
8aaa338f08 | ||
|
|
79b62aaaea | ||
|
|
02b3b03618 | ||
|
|
0acd52bcb5 | ||
|
|
02f00f9b83 | ||
|
|
7d75faab81 | ||
|
|
0eddebf75e | ||
|
|
baab01b031 | ||
|
|
034c4b6e5c | ||
|
|
566027307e | ||
|
|
e8dbc42da7 | ||
|
|
adfc70d2f3 | ||
|
|
9da6aa25e5 | ||
|
|
acf9008e49 | ||
|
|
7e32b7d0da | ||
|
|
c14ab8d543 | ||
|
|
cd9441f90e | ||
|
|
ac296614af | ||
|
|
5328ec28b5 | ||
|
|
de6cc998df | ||
|
|
03b7d245b5 | ||
|
|
a6204f1f3a | ||
|
|
c2dd2d935d | ||
|
|
9f04ad5318 | ||
|
|
272fcfa34c | ||
|
|
997007191a | ||
|
|
9790d9d81c | ||
|
|
ae0ab270ff | ||
|
|
e865160f46 | ||
|
|
c7a360b9bb | ||
|
|
9e68efc05b | ||
|
|
b8ceaa3ed4 | ||
|
|
02a8a61de4 | ||
|
|
6a68afa6cc | ||
|
|
3c5c7d6077 | ||
|
|
d93a9a4be4 | ||
|
|
2b6aed3dc7 | ||
|
|
53d1d825ee | ||
|
|
6db95ffc1e | ||
|
|
cef8682971 | ||
|
|
67d694dd9c | ||
|
|
7b25b8ed80 | ||
|
|
c2e4f28c3f | ||
|
|
8e26753cda | ||
|
|
edfe18495e | ||
|
|
8ede2ff197 | ||
|
|
f1696089ef | ||
|
|
a4925a7954 | ||
|
|
085b4109e3 | ||
|
|
4a6942b8e9 | ||
|
|
1efd1c20c9 | ||
|
|
d8c00ad64e | ||
|
|
e2f472508d | ||
|
|
38060a67ba | ||
|
|
6c199da5f6 | ||
|
|
42b04e0238 | ||
|
|
b37b703f1c | ||
|
|
94cad99398 | ||
|
|
d7dc5628e2 | ||
|
|
8b45d15486 | ||
|
|
e8f93ffc67 | ||
|
|
2e0f86f312 | ||
|
|
167caf28d0 | ||
|
|
2485e58daa | ||
|
|
36bad2c06e | ||
|
|
7c7bca2e7b | ||
|
|
58552b8cb9 | ||
|
|
b064c12aca | ||
|
|
959a89ef67 | ||
|
|
1cf0e13b61 | ||
|
|
37541674a9 | ||
|
|
18e0854d00 | ||
|
|
04914f8c33 | ||
|
|
c2a2fe370f | ||
|
|
65d35f3261 | ||
|
|
b4dea2a473 | ||
|
|
bfa20f0413 | ||
|
|
05c7788a8f | ||
|
|
35ab895667 | ||
|
|
178a2bf8b4 | ||
|
|
673a2515e8 | ||
|
|
93d57cf58c | ||
|
|
4f935ae226 | ||
|
|
8eba763b33 | ||
|
|
85029c85e5 | ||
|
|
e08699893a | ||
|
|
b714d73e46 | ||
|
|
72cd6e42ee | ||
|
|
9baf83e129 | ||
|
|
2d04344265 | ||
|
|
226e365523 | ||
|
|
bcc1264dff |
No files matched your search
@@ -20,6 +20,13 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
|
||||
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
|
||||
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
|
||||
- **Review only when asked, and decide.** Review a pull request only when its author or a maintainer asks.
|
||||
Asked by a maintainer to review open pull requests in general, take them one at a time, oldest first, skipping
|
||||
drafts, your person's own and those they already reviewed. Check that the change does what it says (bugs, edge
|
||||
cases), what it could break, whether tests cover it, and whether its scope and description hold up. Report only
|
||||
what you can point to with a `file:line`, each point marked Blocker (fix before merge), Important, Nit,
|
||||
Suggestion, Question or Praise. Submit a decision, never a plain comment: `gh pr review --approve` when nothing
|
||||
blocks, `--request-changes` when something does.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
|
||||
Generated
+46
@@ -706,6 +706,25 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
@@ -926,6 +945,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -1554,6 +1579,7 @@ dependencies = [
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rayon",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3664,6 +3690,26 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rc2"
|
||||
version = "0.8.1"
|
||||
|
||||
@@ -358,3 +358,29 @@ state of Starling's `crates/horizon-client-render/src/touch.rs` at the same comm
|
||||
|
||||
`crates/horizon-client-render/src/android_runtime.rs` and `android_runtime/tests/` are adapted from the
|
||||
input part of Starling's `crates/horizon-client-render/src/android_runtime.rs` at the same commit.
|
||||
|
||||
## PCG3D hash
|
||||
|
||||
`hash3` in `crates/horizon-client-render/src/hdr.wgsl`, the noise HDR10 output's dither is made from, is the
|
||||
`pcg3d` hash of Mark Jarzynski and Marc Olano ("Hash Functions for GPU Rendering", Journal of Computer Graphics
|
||||
Techniques 9(3), 2020), from <https://github.com/markjarzynski/PCG3D>. Copyright 2020 Mark Jarzynski & Marc Olano,
|
||||
licensed under MIT:
|
||||
|
||||
```
|
||||
Copyright 2020 Mark Jarzynski & Marc Olano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
|
||||
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
@@ -74,11 +74,70 @@ pub trait ResourceSource {
|
||||
vec!["minecraft".into()]
|
||||
}
|
||||
}
|
||||
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
|
||||
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
|
||||
fn bundled_code(code: &str) -> (&str, bool) {
|
||||
match code {
|
||||
"ko" => ("ko_kr", false),
|
||||
"hr" => ("hr_hr", false),
|
||||
"de_at" => ("de_de", false),
|
||||
"de_ch" => ("de_de", true),
|
||||
"fr_ca" | "fr_ch" => ("fr_fr", false),
|
||||
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
|
||||
code => (code, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
|
||||
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
|
||||
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
|
||||
/// counts.
|
||||
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
|
||||
let _ = CRATE_FILES.set(files);
|
||||
}
|
||||
|
||||
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
|
||||
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
|
||||
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let mut own = HashMap::new();
|
||||
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
|
||||
read_lang_file(&bytes, &mut own);
|
||||
}
|
||||
for (c, json) in files.iter().flat_map(|f| f.iter()) {
|
||||
if *c != file {
|
||||
continue;
|
||||
}
|
||||
let mut theirs = HashMap::new();
|
||||
if swiss {
|
||||
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
|
||||
} else {
|
||||
read_lang_file(json.as_bytes(), &mut theirs);
|
||||
}
|
||||
for (key, value) in theirs {
|
||||
if !own.contains_key(&key) {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
let code = path
|
||||
.strip_prefix("assets/horizon/lang/")?
|
||||
.strip_suffix(".json")?;
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let path = format!("assets/horizon/lang/{file}.json");
|
||||
let body = match path.as_str() {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
@@ -103,10 +162,10 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
|
||||
"assets/horizon/lang/ko_kr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
|
||||
}
|
||||
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
|
||||
"assets/horizon/lang/hr_hr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
|
||||
}
|
||||
"assets/horizon/lang/pt_pt.json" => {
|
||||
@@ -331,30 +390,20 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/vec_it.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
"assets/horizon/lang/de_de.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
"assets/horizon/lang/fr_fr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
"assets/horizon/lang/es_es.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if swiss {
|
||||
return Some(body.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
Some(body.as_bytes().to_vec())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
@@ -438,6 +487,16 @@ impl Language {
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
/// override).
|
||||
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
|
||||
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
|
||||
Self::load_with(sources, code, crate_files)
|
||||
}
|
||||
|
||||
/// [`Language::load`] with these crates' lang files.
|
||||
fn load_with(
|
||||
sources: &[&dyn ResourceSource],
|
||||
code: &str,
|
||||
crate_files: &[&[(&str, &str)]],
|
||||
) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
let mut codes = vec![DEFAULT];
|
||||
if code != DEFAULT {
|
||||
@@ -453,12 +512,14 @@ impl Language {
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
for namespace in &namespaces {
|
||||
for source in sources {
|
||||
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
read_lang_file(&bytes, &mut map);
|
||||
for (i, source) in sources.iter().enumerate() {
|
||||
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
// The crates' text right after the engine's own (`Bundled`, the first source).
|
||||
if i == 0 && namespace == "horizon" {
|
||||
read_crate_files(crate_files, c, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +749,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crates_text_loads_after_the_engines_and_before_packs() {
|
||||
static CRATE: &[(&str, &str)] = &[
|
||||
(
|
||||
"en_us",
|
||||
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
|
||||
),
|
||||
(
|
||||
"de_de",
|
||||
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
|
||||
),
|
||||
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
|
||||
];
|
||||
let pack = Mem(HashMap::from([(
|
||||
"assets/horizon/lang/en_us.json".to_owned(),
|
||||
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
|
||||
)]));
|
||||
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
|
||||
let en = load("en_us");
|
||||
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
|
||||
// The engine's own file wins on a key both have; a pack wins over both.
|
||||
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
|
||||
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
|
||||
let de = load("de_de");
|
||||
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
// Regional variants read their language's file, as the engine's do.
|
||||
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
|
||||
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
|
||||
// A language the crate lacks falls back to its English.
|
||||
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
|
||||
// Without crate files, nothing changes.
|
||||
let plain = Language::load_with(&[&pack], "de_de", &[]);
|
||||
assert_eq!(plain.get("test.crate.hello"), None);
|
||||
assert_eq!(plain.len() + 1, de.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
@@ -739,6 +837,20 @@ mod tests {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
|
||||
/// registered through `horizon_hooks::lang`), with that folder.
|
||||
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
|
||||
.map(|(name, path)| (name, path.join("assets/lang")))
|
||||
.filter(|(_, dir)| dir.join("en_us.json").is_file())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
@@ -776,11 +888,33 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let engine = check_lang_dir(&bundled_dir(), true);
|
||||
assert!(
|
||||
engine.1 >= 7,
|
||||
"found {} bundled lang files in {}",
|
||||
engine.1,
|
||||
bundled_dir().display()
|
||||
);
|
||||
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
|
||||
// files also have, the engine's text would win and hide the crate's.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
let (en, _) = check_lang_dir(&dir, false);
|
||||
for key in en.keys() {
|
||||
assert!(
|
||||
!engine.0.contains_key(key),
|
||||
"{name}: {key} is also the engine's"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
|
||||
/// same placeholders as English, no empty text. Its en_us and how many files it has.
|
||||
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
@@ -789,7 +923,7 @@ mod tests {
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
!bundled || Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
@@ -815,11 +949,7 @@ mod tests {
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
(en, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -834,10 +964,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let english = |dir: &std::path::Path| -> HashMap<String, String> {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
|
||||
};
|
||||
let mut en = english(&bundled_dir());
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
@@ -847,6 +977,11 @@ mod tests {
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
// A crate that ships lang files names its keys in its own sources.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
en.extend(english(&dir));
|
||||
rust_files(&crates.join(name).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
|
||||
@@ -27,6 +27,9 @@ horizon-mcp = { path = "../horizon-mcp" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
|
||||
# once, not for every phase of every tick.
|
||||
rayon = "1.10"
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -398,7 +398,37 @@ impl Biomes {
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
/// The biome at `id`, clamped to the last entry when `id` is past the
|
||||
/// table (a stale column from an older registry keeps its id).
|
||||
///
|
||||
/// When the table is empty (a resource pack with no biome files), returns
|
||||
/// a shared default plains biome instead of panicking on `list.len() - 1`.
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
if self.list.is_empty() {
|
||||
static DEFAULT: OnceLock<BiomeInfo> = OnceLock::new();
|
||||
return DEFAULT.get_or_init(|| BiomeInfo {
|
||||
spawns: HashMap::new(),
|
||||
spawn_costs: HashMap::new(),
|
||||
creature_probability: 0.0,
|
||||
increased_fire_burnout: false,
|
||||
name: "minecraft:plains".to_owned(),
|
||||
temperature: 0.8,
|
||||
frozen: false,
|
||||
downfall: 0.4,
|
||||
precipitation: false,
|
||||
grass: 0x7f_c0_55,
|
||||
modifier: GrassModifier::None,
|
||||
foliage: 0x6a_ab_37,
|
||||
dry_foliage: 0x79_75_54,
|
||||
water: 0x3f_76_e4,
|
||||
sky: None,
|
||||
fog: None,
|
||||
water_fog: 0x3f_76_e4,
|
||||
ambient_particles: Vec::new(),
|
||||
features: Vec::new(),
|
||||
attributes: Default::default(),
|
||||
});
|
||||
}
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
/// The name of biome `id`, plains (as for an unknown name) when the id is
|
||||
@@ -795,4 +825,34 @@ mod blend_tests {
|
||||
}
|
||||
assert!(at(-2.0) > 0.0 && at(-2.0) < 0.5);
|
||||
}
|
||||
|
||||
/// An empty biome table (a resource pack with no biome files) must not
|
||||
/// panic on `get`: before the fix, `list.len() - 1` underflowed.
|
||||
#[test]
|
||||
fn get_on_empty_biome_table_returns_default_instead_of_panicking() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let empty = Biomes {
|
||||
builtin: Arc::new(Vec::new()),
|
||||
index: HashMap::new(),
|
||||
list: Vec::new(),
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
}),
|
||||
packs: BTreeMap::new(),
|
||||
};
|
||||
// Any id returns the shared default plains biome, no panic.
|
||||
let b = empty.get(0);
|
||||
assert_eq!(b.name, "minecraft:plains");
|
||||
let b = empty.get(65535);
|
||||
assert_eq!(b.name, "minecraft:plains");
|
||||
// Colours and temperature come from the default.
|
||||
assert_eq!(b.temperature, 0.8);
|
||||
assert_eq!(b.grass, 0x7f_c0_55);
|
||||
assert_eq!(b.foliage, 0x6a_ab_37);
|
||||
assert_eq!(b.water, 0x3f_76_e4);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,71 @@ pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// `PotionContents.getAllEffects`, retaining each custom instance's flags.
|
||||
pub fn contents_instances(stack: &ItemStack) -> Vec<crate::effects::EffectInstance> {
|
||||
use horizon_client_core::nbt_value::{number, text};
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut effects: Vec<_> = stack
|
||||
.potion()
|
||||
.and_then(potion)
|
||||
.map_or(&[][..], |p| p.effects)
|
||||
.iter()
|
||||
.map(|(id, duration, amp)| crate::effects::EffectInstance::new(id, *duration, *amp))
|
||||
.collect();
|
||||
if let Some((_, Tag::List(custom))) = stack
|
||||
.data()
|
||||
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_effects"))
|
||||
{
|
||||
for e in custom {
|
||||
let Some(def) = text(e.get("id")).and_then(|id| effect(&id)) else {
|
||||
continue;
|
||||
};
|
||||
let mut instance = crate::effects::EffectInstance::new(
|
||||
def.id,
|
||||
number(e.get("duration")).unwrap_or(0) as i32,
|
||||
number(e.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32,
|
||||
);
|
||||
instance.ambient = number(e.get("ambient")).unwrap_or(0) != 0;
|
||||
instance.visible = number(e.get("show_particles")) != Some(0);
|
||||
effects.push(instance);
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
/// `PotionContents.getColor`: an explicit colour wins; otherwise mix only
|
||||
/// visible base and custom effects, with their amplifier weights.
|
||||
pub fn contents_color(stack: &ItemStack) -> u32 {
|
||||
use horizon_client_core::nbt_value::number;
|
||||
if let Some(color) = stack
|
||||
.data()
|
||||
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_color"))
|
||||
.and_then(|(_, v)| number(Some(v)))
|
||||
{
|
||||
return color as u32;
|
||||
}
|
||||
0xFF00_0000
|
||||
| horizon_client_core::potions::effects_color(
|
||||
contents_instances(stack)
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.map(|e| (e.id, e.amplifier)),
|
||||
)
|
||||
}
|
||||
/// `PotionContents.equals(EMPTY)`: colour and custom name also make contents
|
||||
/// nonempty, even when there is no registered potion or custom effect.
|
||||
pub fn contents_empty(stack: &ItemStack) -> bool {
|
||||
use horizon_world::nbt::Tag;
|
||||
stack.potion().is_none()
|
||||
&& stack.data().is_none_or(|d| {
|
||||
d.potion_extra
|
||||
.iter()
|
||||
.all(|(key, value)| match key.as_str() {
|
||||
"custom_effects" => matches!(value, Tag::List(list) if list.is_empty()),
|
||||
"custom_color" | "custom_name" => false,
|
||||
_ => true,
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
|
||||
pub fn tooltip(
|
||||
potion: Option<&str>,
|
||||
@@ -706,4 +771,39 @@ mod tests {
|
||||
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
|
||||
assert_eq!(lines[0].0, "resistance");
|
||||
}
|
||||
#[test]
|
||||
fn custom_contents_color_and_empty_semantics() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut stack = ItemStack::new("minecraft:tipped_arrow", 1);
|
||||
assert!(contents_empty(&stack));
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_effects".into(), Tag::List(Vec::new())));
|
||||
assert!(contents_empty(&stack));
|
||||
stack.data_mut().potion_extra = vec![(
|
||||
"custom_effects".into(),
|
||||
Tag::List(vec![
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:glowing".into())),
|
||||
("amplifier", Tag::Int(1)),
|
||||
]),
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:poison".into())),
|
||||
("show_particles", Tag::Byte(0)),
|
||||
]),
|
||||
]),
|
||||
)];
|
||||
assert!(!contents_empty(&stack));
|
||||
// Glowing's colour; hidden poison contributes no weight.
|
||||
assert_eq!(contents_color(&stack), 0xFF94_A061);
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_color".into(), Tag::Int(123456)));
|
||||
assert_eq!(contents_color(&stack), 123456);
|
||||
stack.data_mut().potion_extra = vec![("custom_name".into(), Tag::String("test".into()))];
|
||||
assert!(!contents_empty(&stack));
|
||||
assert_eq!(contents_color(&stack), 0xFF38_5DC6);
|
||||
}
|
||||
}
|
||||
@@ -539,6 +539,7 @@ pub struct Data {
|
||||
pub current: Option<Info>,
|
||||
pub travel: i32,
|
||||
pub selector: Selector,
|
||||
pub reload_particle: bool,
|
||||
}
|
||||
/// Which sculk block entity this is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Data packs (`<world>/datapacks`: folders and zips with `pack.mcmeta`) and
|
||||
//! the parts of them commands use: `.mcfunction` functions (with macros and
|
||||
//! line continuation), function tags and predicates.
|
||||
mod slot_sources;
|
||||
use crate::zip::Zip;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
@@ -663,6 +664,8 @@ pub struct DataStore {
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// `slot_source` definitions, evaluated against a command's live accessor.
|
||||
pub slot_sources: BTreeMap<String, serde_json::Value>,
|
||||
/// Slot-source tags belong to the same loaded snapshot as their definitions.
|
||||
pub slot_source_tags: BTreeMap<String, Vec<(String, bool)>>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
@@ -759,6 +762,16 @@ impl DataStore {
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.item_modifiers.insert(id, v);
|
||||
} else if let Some(id) = resource_id(&f, "tags/slot_source", ".json")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
{
|
||||
let entries = tag_entries(&v);
|
||||
let slot = s.slot_source_tags.entry(id).or_default();
|
||||
if v["replace"].as_bool() == Some(true) {
|
||||
slot.clear();
|
||||
}
|
||||
slot.extend(entries);
|
||||
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Slot-source holder-set tags share DataStore's pack ordering and snapshot.
|
||||
use super::DataStore;
|
||||
use std::collections::BTreeSet;
|
||||
impl DataStore {
|
||||
/// TagLoader's required-member validation and first-seen membership order.
|
||||
pub fn slot_source_tag(&self, id: &str) -> Result<Vec<String>, String> {
|
||||
fn visit(
|
||||
store: &DataStore,
|
||||
id: &str,
|
||||
active: &mut BTreeSet<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !active.insert(id.to_owned()) {
|
||||
return Err(format!("Recursive slot-source tag #{id}"));
|
||||
}
|
||||
let result = (|| {
|
||||
let entries = store
|
||||
.slot_source_tags
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("Unknown slot-source tag #{id}"))?;
|
||||
let mut out = Vec::new();
|
||||
for (entry, required) in entries {
|
||||
let values = if let Some(tag) = entry.strip_prefix('#') {
|
||||
visit(store, tag, active)
|
||||
} else if store.slot_sources.contains_key(entry) {
|
||||
Ok(vec![entry.clone()])
|
||||
} else {
|
||||
Err(format!("Unknown slot source {entry}"))
|
||||
};
|
||||
match values {
|
||||
Ok(values) => {
|
||||
for value in values {
|
||||
if !out.contains(&value) {
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if *required => return Err(error),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})();
|
||||
active.remove(id);
|
||||
result
|
||||
}
|
||||
visit(self, id, &mut BTreeSet::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn holder_set_tags_keep_order_required_members_and_snapshot_authority() {
|
||||
let mut store = DataStore::default();
|
||||
for name in ["t:a", "t:b"] {
|
||||
store.slot_sources.insert(
|
||||
name.into(),
|
||||
serde_json::json!({"type":"slot_range","slots":"container.0"}),
|
||||
);
|
||||
}
|
||||
store.slot_source_tags.insert(
|
||||
"t:child".into(),
|
||||
vec![("t:b".into(), true), ("t:a".into(), true)],
|
||||
);
|
||||
store.slot_source_tags.insert(
|
||||
"t:all".into(),
|
||||
vec![
|
||||
("t:a".into(), true),
|
||||
("#t:child".into(), true),
|
||||
("t:absent".into(), false),
|
||||
],
|
||||
);
|
||||
assert_eq!(store.slot_source_tag("t:all").unwrap(), ["t:a", "t:b"]);
|
||||
store
|
||||
.slot_source_tags
|
||||
.get_mut("t:all")
|
||||
.unwrap()
|
||||
.push(("t:absent".into(), true));
|
||||
assert!(store.slot_source_tag("t:all").is_err());
|
||||
store
|
||||
.slot_source_tags
|
||||
.insert("t:cycle".into(), vec![("#t:cycle".into(), true)]);
|
||||
assert!(store.slot_source_tag("t:cycle").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! A bind group's validity depends on the identity of its buffers and the
|
||||
//! bound slice, not on the bytes written into those buffers.
|
||||
|
||||
pub(super) struct BindingKey<B, const N: usize> {
|
||||
pub first: u64,
|
||||
pub count: u64,
|
||||
pub buffers: [B; N],
|
||||
}
|
||||
|
||||
impl<B: PartialEq, const N: usize> BindingKey<B, N> {
|
||||
pub fn matches(&self, first: u64, count: u64, buffers: [&B; N]) -> bool {
|
||||
self.first == first
|
||||
&& self.count == count
|
||||
&& self
|
||||
.buffers
|
||||
.iter()
|
||||
.zip(buffers)
|
||||
.all(|(old, new)| old == new)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn changes_to_any_resource_or_range_invalidate_the_binding() {
|
||||
let key = BindingKey {
|
||||
first: 16,
|
||||
count: 8,
|
||||
buffers: [1u64, 2, 3, 4],
|
||||
};
|
||||
assert!(key.matches(16, 8, [&1, &2, &3, &4]));
|
||||
assert!(!key.matches(0, 8, [&1, &2, &3, &4]));
|
||||
assert!(!key.matches(16, 9, [&1, &2, &3, &4]));
|
||||
for i in 0..4 {
|
||||
let mut resources = [1u64, 2, 3, 4];
|
||||
resources[i] += 100;
|
||||
assert!(!key.matches(16, 8, resources.each_ref()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,10 @@
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::SolidPipeline;
|
||||
use super::arena::GRAIN;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[path = "binding_cache.rs"]
|
||||
mod binding_cache;
|
||||
use binding_cache::BindingKey;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
|
||||
@@ -85,6 +88,11 @@ pub struct Call {
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
struct CachedGroup<const N: usize> {
|
||||
key: BindingKey<wgpu::Buffer, N>,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct Flat {
|
||||
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
|
||||
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
|
||||
@@ -109,8 +117,9 @@ pub struct Flat {
|
||||
phase2_call_data: Vec<[u32; 5]>,
|
||||
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
|
||||
indirect_execution: bool,
|
||||
/// The copy of phase 2's draws, and its ranges.
|
||||
phase2_group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// Bindings reused while the range and all GPU buffers are unchanged.
|
||||
phase2_group: Option<CachedGroup<4>>,
|
||||
groups: Vec<CachedGroup<3>>,
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
pub(super) draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
@@ -515,6 +524,7 @@ impl Flat {
|
||||
phase2_call_data: Vec::new(),
|
||||
indirect_execution,
|
||||
phase2_group: None,
|
||||
groups: Vec::new(),
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
@@ -626,6 +636,8 @@ impl Flat {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.groups.clear();
|
||||
self.phase2_group = None;
|
||||
self.free_ids.clear();
|
||||
self.next_id = 0;
|
||||
}
|
||||
@@ -665,7 +677,6 @@ impl Flat {
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase2 = phase2.filter(|_| self.indirect_execution);
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
phase1_draws
|
||||
@@ -729,6 +740,9 @@ impl Flat {
|
||||
self.shadow_calls = shadow_calls;
|
||||
self.pass_calls = pass_calls;
|
||||
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
|
||||
// Do not retain bindings for a frame that cannot use flat draws.
|
||||
self.phase2_group = None;
|
||||
self.groups.clear();
|
||||
self.calls.clear();
|
||||
self.phase1_calls = 0;
|
||||
self.solid_calls = 0;
|
||||
@@ -802,39 +816,63 @@ impl Flat {
|
||||
phase2,
|
||||
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
|
||||
) {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
layout: &self.seen_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ranges_at(first, count),
|
||||
let buffers = [&self.ranges, &self.indices, phase2, &self.phase2_calls];
|
||||
if !self
|
||||
.phase2_group
|
||||
.as_ref()
|
||||
.is_some_and(|cached| cached.key.matches(first, count, buffers))
|
||||
{
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
layout: &self.seen_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ranges_at(first, count),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.indices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: phase2.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.phase2_calls.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.phase2_group = Some(CachedGroup {
|
||||
key: BindingKey {
|
||||
first,
|
||||
count,
|
||||
buffers: buffers.map(|b| b.clone()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.indices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: phase2.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.phase2_calls.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.phase2_group = Some((group, count as u32));
|
||||
group,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.phase2_group = None;
|
||||
}
|
||||
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
|
||||
.segments
|
||||
.iter()
|
||||
.filter_map(|&(source, first, count)| {
|
||||
let sorted = match source {
|
||||
Source::Quads => None,
|
||||
Source::Seen => return None,
|
||||
Source::Sorted(page) => Some(sorted_page(page)),
|
||||
};
|
||||
let mut groups = 0;
|
||||
for &(source, first, count) in &self.segments {
|
||||
let sorted = match source {
|
||||
Source::Quads => None,
|
||||
Source::Seen => continue,
|
||||
Source::Sorted(page) => Some(sorted_page(page)),
|
||||
};
|
||||
let buffers = [
|
||||
&self.ranges,
|
||||
sorted.as_ref().unwrap_or(&self.empty),
|
||||
&self.indices,
|
||||
];
|
||||
if self
|
||||
.groups
|
||||
.get(groups)
|
||||
.is_none_or(|cached| !cached.key.matches(first, count, buffers))
|
||||
{
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: &self.compute_layout,
|
||||
@@ -845,7 +883,7 @@ impl Flat {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: sorted.as_ref().unwrap_or(&self.empty).as_entire_binding(),
|
||||
resource: buffers[1].as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
@@ -853,17 +891,32 @@ impl Flat {
|
||||
},
|
||||
],
|
||||
});
|
||||
Some((group, count as u32))
|
||||
})
|
||||
.collect();
|
||||
let cached = CachedGroup {
|
||||
key: BindingKey {
|
||||
first,
|
||||
count,
|
||||
buffers: buffers.map(|b| b.clone()),
|
||||
},
|
||||
group,
|
||||
};
|
||||
if groups < self.groups.len() {
|
||||
self.groups[groups] = cached;
|
||||
} else {
|
||||
self.groups.push(cached);
|
||||
}
|
||||
}
|
||||
groups += 1;
|
||||
}
|
||||
self.groups.truncate(groups);
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
});
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for (group, count) in &groups {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
for cached in &self.groups {
|
||||
let count = cached.key.count as u32;
|
||||
pass.set_bind_group(0, &cached.group, &[]);
|
||||
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
drop(pass);
|
||||
true
|
||||
@@ -919,16 +972,17 @@ impl Flat {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some((group, count)) = &self.phase2_group else {
|
||||
let Some(cached) = &self.phase2_group else {
|
||||
return;
|
||||
};
|
||||
let count = cached.key.count as u32;
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
|
||||
});
|
||||
pass.set_pipeline(&self.flatten_seen);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
pass.set_bind_group(0, &cached.group, &[]);
|
||||
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// The camera's translucent calls.
|
||||
|
||||
@@ -169,7 +169,8 @@ pub struct Hdr {
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
|
||||
/// so it can differ from the swapchain's in one frame.
|
||||
/// so it can differ from the swapchain's in one frame, and without the swapchain's dither:
|
||||
/// a screenshot keeps the exact codes.
|
||||
capture_pipeline: wgpu::RenderPipeline,
|
||||
capture_params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
@@ -286,8 +287,9 @@ impl Hdr {
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`; `dither`
|
||||
// for the last pass into the HDR10 swapchain (`pq_dither`).
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format, dither: bool| {
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
@@ -308,7 +310,10 @@ impl Hdr {
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("pq_dither", f64::from(u8::from(dither)))],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
@@ -321,7 +326,8 @@ impl Hdr {
|
||||
};
|
||||
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
|
||||
let display = layout("Horizon HDR bindings", layout_bindings, true);
|
||||
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
|
||||
let pipeline_for =
|
||||
|format, dither| pass("Horizon HDR", &display, entry_point, format, dither);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
@@ -333,7 +339,7 @@ impl Hdr {
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry, format| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
let pipeline = pass(label, &layout, entry, format, false);
|
||||
(layout, pipeline)
|
||||
};
|
||||
let hud = super::hud_coverage::HUD_FORMAT;
|
||||
@@ -395,8 +401,8 @@ impl Hdr {
|
||||
);
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
pipeline: pipeline_for(output.format()),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
pipeline: pipeline_for(output.format(), output == Output::Pq),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format(), false),
|
||||
layout: display,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
@@ -1709,19 +1715,32 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
hdr.begin(&queue, true);
|
||||
hdr.present(&queue, &mut encoder, &target, true, None);
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
for c in &got[..3] {
|
||||
let c = f64::from(*c);
|
||||
let ok = match output {
|
||||
// 10-bit codes, a step either way for rounding.
|
||||
Output::Pq => (c - 1023.0 * pq(want)).abs() <= 1.0,
|
||||
// 80 nits a unit, in half floats.
|
||||
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
|
||||
// HDR10's exact codes are a screenshot's copy: the swapchain's own get a
|
||||
// code of dither either way.
|
||||
let exact = (output == Output::Pq)
|
||||
.then(|| hdr.capture(&device, &queue, &mut encoder, true).unwrap());
|
||||
let presented = texel(&device, &queue, encoder, &target);
|
||||
let exact = exact.map(|copy| {
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
texel(&device, &queue, encoder, ©)
|
||||
});
|
||||
for (got, codes) in [(Some(presented), 2.0), (exact, 1.0)] {
|
||||
let Some(got) = got else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
ok,
|
||||
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
|
||||
);
|
||||
for c in &got[..3] {
|
||||
let c = f64::from(*c);
|
||||
let ok = match output {
|
||||
// 10-bit codes, a step either way for rounding.
|
||||
Output::Pq => (c - 1023.0 * pq(want)).abs() <= codes,
|
||||
// 80 nits a unit, in half floats.
|
||||
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
|
||||
};
|
||||
assert!(
|
||||
ok,
|
||||
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2078,3 +2097,7 @@ mod lighting_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "hdr_written_tests.rs"]
|
||||
mod written_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hdr_dither_tests.rs"]
|
||||
mod dither_tests;
|
||||
@@ -0,0 +1,313 @@
|
||||
//! HDR10's dither (`hdr.wgsl`, `dithered`): the last pass into the 10-bit swapchain, and what
|
||||
//! must stay without it.
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{fill, gpu},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
|
||||
const SIZE: [u32; 2] = [64, 1024];
|
||||
/// The ramp: grey rising by one half-float step every `ROWS` rows from `FIRST / 2048`. That is
|
||||
/// three PQ codes down the picture around 91 nits: as slow as a clear sky, and as fine as the
|
||||
/// float frame holds.
|
||||
const FIRST: u32 = 1434;
|
||||
const ROWS: u32 = 52;
|
||||
|
||||
/// SMPTE ST 2084: nits as a 10-bit PQ code, before rounding.
|
||||
fn code(nits: f64) -> f64 {
|
||||
let y = (nits / 10_000.0).powf(0.1593017578125);
|
||||
1023.0 * ((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y)).powf(78.84375)
|
||||
}
|
||||
|
||||
/// The ramp's row `y` as a PQ code, at the 200-nit paper white `Hdr` starts with.
|
||||
fn ramp_code(y: u32) -> f64 {
|
||||
code(200.0 * (f64::from(FIRST + y / ROWS) / 2048.0).powf(2.2))
|
||||
}
|
||||
|
||||
fn ramp(device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture) {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
format!(
|
||||
r#"
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
|
||||
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}}
|
||||
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {{
|
||||
return vec4(vec3(f32({FIRST}u + u32(pos.y) / {ROWS}u) / 2048.0), 1.0);
|
||||
}}
|
||||
"#
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(frame.format().into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(super::super::hud_coverage::attachment(&view))],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// What `hdr` writes for the float frame `paint` fills, with no HUD over it: its last pass into
|
||||
/// a swapchain image of its output (`Hdr::present`), or a screenshot's HDR10 copy
|
||||
/// (`Hdr::capture`). Every pixel's bytes, row by row.
|
||||
fn shown(
|
||||
(device, queue): &(wgpu::Device, wgpu::Queue),
|
||||
hdr: &mut Hdr,
|
||||
screenshot: bool,
|
||||
paint: impl Fn(&mut wgpu::CommandEncoder, &wgpu::Texture),
|
||||
) -> Vec<u8> {
|
||||
let frame = hdr.frame(device, SIZE);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
paint(&mut encoder, &frame);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
let image = if screenshot {
|
||||
hdr.capture(device, queue, &mut encoder, true).unwrap()
|
||||
} else {
|
||||
let image = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: hdr.output.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
hdr.present(queue, &mut encoder, &image, true, None);
|
||||
image
|
||||
};
|
||||
let row = SIZE[0] * image.format().block_copy_size(None).unwrap();
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(row * SIZE[1]),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
image.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(row),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
image.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
}
|
||||
|
||||
fn float_hdr((device, queue): &(wgpu::Device, wgpu::Queue), output: Output) -> Hdr {
|
||||
Hdr::with_debug(device, queue, F::Rgba16Float, output, true, None)
|
||||
}
|
||||
|
||||
/// An HDR10 picture's 10-bit codes, red, green and blue.
|
||||
fn codes(bytes: &[u8]) -> Vec<[u32; 3]> {
|
||||
let pixels = bytes.as_chunks::<4>().0.iter();
|
||||
pixels
|
||||
.map(|p| [0, 10, 20].map(|shift| (u32::from_le_bytes(*p) >> shift) & 0x3ff))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Down every column of a channel: the longest run of rows with one code, and the runs' mean
|
||||
/// length.
|
||||
fn runs(codes: &[[u32; 3]], channel: usize) -> (u32, f64) {
|
||||
let (mut longest, mut count) = (0, 0);
|
||||
for x in 0..SIZE[0] {
|
||||
let mut run = 0;
|
||||
for y in 0..SIZE[1] {
|
||||
let at = |y: u32| codes[(y * SIZE[0] + x) as usize][channel];
|
||||
if y > 0 && at(y) != at(y - 1) {
|
||||
(longest, count, run) = (longest.max(run), count + 1, 0);
|
||||
}
|
||||
run += 1;
|
||||
}
|
||||
(longest, count) = (longest.max(run), count + 1);
|
||||
}
|
||||
(longest, f64::from(SIZE[0] * SIZE[1]) / f64::from(count))
|
||||
}
|
||||
|
||||
/// Whether every row of a picture is one value from side to side: no noise in it.
|
||||
fn rows_are_flat(bytes: &[u8]) -> bool {
|
||||
let pixel = bytes.len() / (SIZE[0] * SIZE[1]) as usize;
|
||||
bytes
|
||||
.chunks(pixel * SIZE[0] as usize)
|
||||
.all(|row| row.chunks(pixel).all(|p| p == &row[..pixel]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_gradient_gets_grain_instead_of_bands_on_hdr10() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
ramp(&gpu.0, encoder, frame);
|
||||
};
|
||||
let mut hdr = float_hdr(&gpu, Output::Pq);
|
||||
// The ramp is what the test says: three codes, top to bottom.
|
||||
let span = ramp_code(SIZE[1] - 1) - ramp_code(0);
|
||||
assert!((2.9..3.2).contains(&span), "{span}");
|
||||
// Without dither, as a screenshot's copy stays: each row its nearest code, in bands hundreds
|
||||
// of rows tall. A GPU need not round at exactly half a code: an NVIDIA one rounds up from
|
||||
// 0.55 here, which shows as 0.05 of a code less, so the bounds below leave a quarter code.
|
||||
let plain = codes(&shown(&gpu, &mut hdr, true, ramp));
|
||||
let dithered = codes(&shown(&gpu, &mut hdr, false, ramp));
|
||||
let want = |i: usize| ramp_code(i as u32 / SIZE[0]);
|
||||
for channel in 0..3 {
|
||||
for (i, p) in plain.iter().enumerate() {
|
||||
let off = (f64::from(p[channel]) - want(i)).abs();
|
||||
assert!(
|
||||
off <= 0.75,
|
||||
"plain {channel} at {i}: {p:?}, not {}",
|
||||
want(i)
|
||||
);
|
||||
}
|
||||
let (longest, _) = runs(&plain, channel);
|
||||
assert!(longest >= 250, "plain {channel}: bands of {longest} rows");
|
||||
// With it: each pixel within the noise's one code of its light (and half a code of
|
||||
// rounding), no column holds a code for long, and a run is a few rows on average.
|
||||
for (i, d) in dithered.iter().enumerate() {
|
||||
let off = (f64::from(d[channel]) - want(i)).abs();
|
||||
assert!(
|
||||
off <= 1.75,
|
||||
"dithered {channel} at {i}: {d:?}, not {}",
|
||||
want(i)
|
||||
);
|
||||
}
|
||||
let (longest, mean) = runs(&dithered, channel);
|
||||
eprintln!(
|
||||
"HDR10 dither GPU, channel {channel}: longest run {longest} rows, mean {mean:.2}"
|
||||
);
|
||||
assert!(longest <= 64, "dithered {channel}: a run of {longest} rows");
|
||||
assert!(mean < 4.0, "dithered {channel}: runs of {mean} rows");
|
||||
// The mean of each 64-row block is the light meant.
|
||||
let mut worst = 0.0_f64;
|
||||
for block in 0..SIZE[1] / 64 {
|
||||
let rows = (block * 64 * SIZE[0]) as usize..((block + 1) * 64 * SIZE[0]) as usize;
|
||||
let n = rows.len() as f64;
|
||||
let got = dithered[rows.clone()]
|
||||
.iter()
|
||||
.map(|d| f64::from(d[channel]))
|
||||
.sum::<f64>()
|
||||
/ n;
|
||||
let meant = rows.map(want).sum::<f64>() / n;
|
||||
worst = worst.max((got - meant).abs());
|
||||
assert!(
|
||||
(got - meant).abs() < 0.25,
|
||||
"block {block} of {channel}: {got}, not {meant}"
|
||||
);
|
||||
}
|
||||
eprintln!("HDR10 dither GPU, channel {channel}: block means within {worst:.3} of a code");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr10s_dither_keeps_both_ends_exact_and_leans_to_them_in_their_last_code() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = float_hdr(&gpu, Output::Pq);
|
||||
let grey = |g: f64| {
|
||||
move |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
fill(encoder, frame, [g, g, g, 1.0]);
|
||||
}
|
||||
};
|
||||
// How far the picture's mean code is from `end`.
|
||||
let mean_from = |picture: &[[u32; 3]], end: f64| {
|
||||
let sum: f64 = picture.iter().flatten().map(|c| f64::from(*c)).sum();
|
||||
(sum / (picture.len() * 3) as f64 - end).abs()
|
||||
};
|
||||
// Within a code of an end the noise shrinks to the distance to it. A third of a code from
|
||||
// the end, the signal plus that noise passes half a code only where the noise is over half
|
||||
// its range, an eighth of a triangle: so the picture's mean is an eighth of a code from the
|
||||
// end, not a third. That lean to the end, in the last code alone, is the price of keeping
|
||||
// the end itself exact. Bound: a GPU that rounds a tenth of a code off the half (as above)
|
||||
// shows between 0.02 and 0.32 of a code, always short of the third.
|
||||
let leans = |mean: f64| (0.015..0.325).contains(&mean);
|
||||
|
||||
// Black gets no noise: every pixel stays off.
|
||||
let black = codes(&shown(&gpu, &mut hdr, false, grey(0.0)));
|
||||
assert!(black.iter().all(|p| *p == [0; 3]));
|
||||
// A third of a code above black (this grey is a half float): 0 or 1, never lifted past it.
|
||||
let dim = 0.00038433074951171875;
|
||||
let meant = code(200.0 * f64::powf(dim, 2.2));
|
||||
assert!((meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
|
||||
let near_black = codes(&shown(&gpu, &mut hdr, false, grey(dim)));
|
||||
assert!(near_black.iter().flatten().all(|c| *c <= 1));
|
||||
let mean = mean_from(&near_black, 0.0);
|
||||
eprintln!("HDR10 dither GPU: a third of a code above black shows as {mean:.3}");
|
||||
assert!(leans(mean), "{mean} above black");
|
||||
|
||||
// The top of the range, PQ's 10 000 nits, stays the top: nothing wraps or dims. Light past
|
||||
// the peak is held at it, so the Peak setting is the signal here.
|
||||
hdr.peak = 10_000.0;
|
||||
let top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
|
||||
assert!(top.iter().all(|p| *p == [1023; 3]));
|
||||
// A third of a code under the top, mirrored: 1022 or 1023.
|
||||
let (mut under, mut over) = (9000.0, 10_000.0);
|
||||
for _ in 0..60 {
|
||||
let middle = (under + over) / 2.0;
|
||||
if code(middle) < 1023.0 - 1.0 / 3.0 {
|
||||
under = middle;
|
||||
} else {
|
||||
over = middle;
|
||||
}
|
||||
}
|
||||
hdr.peak = under as f32;
|
||||
let meant = code(f64::from(hdr.peak));
|
||||
assert!((1023.0 - meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
|
||||
let near_top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
|
||||
assert!(near_top.iter().flatten().all(|c| *c >= 1022));
|
||||
let mean = mean_from(&near_top, 1023.0);
|
||||
eprintln!("HDR10 dither GPU: a third of a code under the top shows as {mean:.3} under");
|
||||
assert!(leans(mean), "{mean} under the top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrgb_and_screenshots_get_no_dither() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
ramp(&gpu.0, encoder, frame);
|
||||
};
|
||||
let (mut hdr10, mut scrgb) = (float_hdr(&gpu, Output::Pq), float_hdr(&gpu, Output::ScRgb));
|
||||
// scRGB's swapchain is float: every row is one value from side to side.
|
||||
assert!(rows_are_flat(&shown(&gpu, &mut scrgb, false, ramp)));
|
||||
// A screenshot's HDR10 copy is the same bytes whichever swapchain is presented, the
|
||||
// dithered one included, and has no noise in it.
|
||||
let screenshot = shown(&gpu, &mut hdr10, true, ramp);
|
||||
assert!(rows_are_flat(&screenshot));
|
||||
assert_eq!(screenshot, shown(&gpu, &mut scrgb, true, ramp));
|
||||
// The swapchain's own picture differs from it: the dither is on there.
|
||||
assert_ne!(screenshot, shown(&gpu, &mut hdr10, false, ramp));
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_shader_api::{
|
||||
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
|
||||
Api, Frame as FrameInputs, Primaries, StageKind, check::CheckedStage, stages::GROUP,
|
||||
};
|
||||
|
||||
use super::Frame;
|
||||
@@ -1596,7 +1596,11 @@ pub fn frame_inputs(
|
||||
paper_white: 80.0,
|
||||
peak: 80.0,
|
||||
black_level: 0.0,
|
||||
padding: [0; 3],
|
||||
// No pack is served another space yet (`[frame] primaries`).
|
||||
primaries: Primaries::Bt709 as u32,
|
||||
luminance_gap: [0; 2],
|
||||
luminance: Primaries::Bt709.luminance(),
|
||||
padding: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,13 +173,52 @@ fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
|
||||
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
|
||||
// lifted.
|
||||
fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
// On for the HDR10 swapchain alone (Hdr::new): its 10 bits get dither. A screenshot's HDR10 copy
|
||||
// (Hdr::capture) keeps the exact codes, and scRGB's floats need none.
|
||||
override pq_dither: bool = false;
|
||||
|
||||
// Three even random values in [0, 1) for `at`: PCG's 3D hash (Jarzynski and Olano, "Hash Functions
|
||||
// for GPU Rendering", JCGT 2020). Integers only, so it is the same on every GPU and at every
|
||||
// resolution, where hashes built on `fract(sin())` repeat or streak at 4K's coordinates.
|
||||
// Copyright 2020 Mark Jarzynski & Marc Olano, MIT (github.com/markjarzynski/PCG3D; the license is in
|
||||
// THIRD_PARTY_NOTICES.md).
|
||||
fn hash3(at: vec3<u32>) -> vec3<f32> {
|
||||
var v = at * 1664525u + 1013904223u;
|
||||
v.x += v.y * v.z;
|
||||
v.y += v.z * v.x;
|
||||
v.z += v.x * v.y;
|
||||
v ^= v >> vec3(16u);
|
||||
v.x += v.y * v.z;
|
||||
v.y += v.z * v.x;
|
||||
v.z += v.x * v.y;
|
||||
return vec3<f32>(v >> vec3(8u)) / 16777216.0;
|
||||
}
|
||||
|
||||
// The PQ signal `e` with dither for its 10 bits: triangular noise of one code either way, each
|
||||
// channel's own, so a slow gradient (a clear sky is a few codes tall) shows as grain a code deep
|
||||
// instead of bands, and its mean is the light meant. The noise is the pixel's alone: no frame
|
||||
// counter, so a still picture stays still. Within a code of black or of the top it shrinks to
|
||||
// what fits: the signal never leaves 0..1, and black stays exactly black (an OLED's pixel off).
|
||||
// The price is in that last code alone: the smaller noise keeps the signal's mean but not the
|
||||
// rounded one, which leans to the end (a third of a code from it shows as an eighth). Under
|
||||
// 0.00004 nits at black, that is the accepted cost of a true 0.
|
||||
fn dithered(e: vec3<f32>, pixel: vec2<f32>) -> vec3<f32> {
|
||||
let at = vec2<u32>(pixel);
|
||||
let noise = hash3(vec3(at, 0u)) + hash3(vec3(at, 1u)) - 1.0;
|
||||
return e + noise * min(min(e, 1.0 - e), vec3(1.0 / 1023.0));
|
||||
}
|
||||
|
||||
// Light in units of paper white at the pixel `pos`, as the swapchain takes it: HDR10 or scRGB,
|
||||
// the black level lifted.
|
||||
fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
|
||||
let lifted = select(e, lift_black(e), params.black > 0.0);
|
||||
if pq_dither {
|
||||
return vec4(dithered(lifted, pos.xy), 1.0);
|
||||
}
|
||||
return vec4(lifted, 1.0);
|
||||
}
|
||||
if params.black > 0.0 {
|
||||
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
|
||||
@@ -209,7 +248,7 @@ fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
// The HUD and menus at their own brightness, by how much they cover, no brighter than the
|
||||
// peak (as `fs_hdr_layered`): UI Brightness can pass paper white.
|
||||
light = min(light * mix(1.0, params.ui, ui), vec3(params.peak));
|
||||
return display(light);
|
||||
return display(light, pos);
|
||||
}
|
||||
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
@@ -328,5 +367,5 @@ override screenshot_shoulder: bool = true;
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)), pos);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ pub struct TextureFlags {
|
||||
impl TextureFlags {
|
||||
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
|
||||
pub fn from_mcmeta(json: &str) -> Option<Self> {
|
||||
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let meta = crate::packs::first_json(json.as_bytes())?;
|
||||
let section = meta.get("texture")?;
|
||||
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
|
||||
let flags = Self {
|
||||
@@ -387,7 +387,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
|
||||
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
@@ -3110,6 +3110,18 @@ mod coverage_tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn animated_texture_metadata_ignores_trailing_pack_signature() {
|
||||
let meta = r#"{"animation":{"frametime":2,"width":2,"height":2}}
|
||||
// High Contrast pack signature"#;
|
||||
let animation = animation_of(Some(meta)).expect("the first JSON value is metadata");
|
||||
assert_eq!(animation.frame_size(8, 4), [2, 2]);
|
||||
let pixels = (0..8 * 4).flat_map(|i| [i as u8, 0, 0, 255]).collect();
|
||||
let (_, height, first) = first_frame((8, 4, pixels), Some(&animation));
|
||||
assert_eq!(height, 2);
|
||||
assert_eq!(first.len(), 2 * 2 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_skin_revision_tracks_changes_and_capacity_clear() {
|
||||
let mut atlas = HudAtlas::boot();
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, VecDeque},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
@@ -365,6 +366,48 @@ fn give_scratch(scratch: LightScratch) {
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static RELIGHT_SCRATCH: RefCell<RelightScratch> = RefCell::new(RelightScratch::new());
|
||||
}
|
||||
|
||||
struct RelightScratch {
|
||||
decrease: VecDeque<(Pos, u8)>,
|
||||
cleared: Vec<Pos>,
|
||||
increase: VecDeque<Pos>,
|
||||
sources: FxHashMap<(i32, i32), i32>,
|
||||
}
|
||||
|
||||
impl RelightScratch {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
decrease: VecDeque::new(),
|
||||
cleared: Vec::new(),
|
||||
increase: VecDeque::new(),
|
||||
sources: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
const MAX_CAPACITY: usize = 16_384;
|
||||
self.decrease.clear();
|
||||
if self.decrease.capacity() > MAX_CAPACITY {
|
||||
self.decrease.shrink_to(MAX_CAPACITY);
|
||||
}
|
||||
self.cleared.clear();
|
||||
if self.cleared.capacity() > MAX_CAPACITY {
|
||||
self.cleared.shrink_to(MAX_CAPACITY);
|
||||
}
|
||||
self.increase.clear();
|
||||
if self.increase.capacity() > MAX_CAPACITY {
|
||||
self.increase.shrink_to(MAX_CAPACITY);
|
||||
}
|
||||
self.sources.clear();
|
||||
if self.sources.capacity() > MAX_CAPACITY {
|
||||
self.sources.shrink_to(MAX_CAPACITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a section sits in the 48-wide region.
|
||||
#[derive(Clone, Copy)]
|
||||
struct At {
|
||||
@@ -880,12 +923,12 @@ impl<'w> Region<'w> {
|
||||
Self {
|
||||
layout: world.layout(),
|
||||
world,
|
||||
slots: Vec::new(),
|
||||
slots: Vec::with_capacity(16),
|
||||
index: FxHashMap::default(),
|
||||
last: ((i32::MIN, 0, 0), NONE),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
#[inline(always)]
|
||||
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
let s = self.layout.section_index(y)?;
|
||||
let key = (x >> 4, s as i32, z >> 4);
|
||||
@@ -1142,10 +1185,25 @@ pub fn relight(
|
||||
force: bool,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
let mut region = Region::new(world);
|
||||
changed.keyed(region.layout);
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
changed.keyed(world.layout());
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
RELIGHT_SCRATCH.with_borrow_mut(|scratch| {
|
||||
scratch.clear();
|
||||
let mut region = Region::new(world);
|
||||
relight_region(
|
||||
&mut region,
|
||||
tables,
|
||||
sky_light,
|
||||
positions,
|
||||
force,
|
||||
scratch,
|
||||
changed,
|
||||
);
|
||||
region.finish();
|
||||
scratch.clear();
|
||||
});
|
||||
}
|
||||
/// The columns `changed` touched that border a loaded column not lit yet
|
||||
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
|
||||
@@ -1170,10 +1228,11 @@ fn relight_region(
|
||||
sky_light: bool,
|
||||
positions: &[Pos],
|
||||
force: bool,
|
||||
scratch: &mut RelightScratch,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
crate::spans::time("lt_block", || {
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed)
|
||||
relight_layer(world, tables, false, positions, force, scratch, changed)
|
||||
});
|
||||
if sky_light {
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
@@ -1201,7 +1260,7 @@ fn relight_region(
|
||||
}
|
||||
});
|
||||
crate::spans::time("lt_sky", || {
|
||||
relight_layer(world, tables, true, seeds, force, changed)
|
||||
relight_layer(world, tables, true, &seeds, force, scratch, changed)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1209,14 +1268,30 @@ fn relight_layer(
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
sky: bool,
|
||||
seeds: Vec<Pos>,
|
||||
seeds: &[Pos],
|
||||
force: bool,
|
||||
scratch: &mut RelightScratch,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
let mut decrease: VecDeque<(Pos, u8)> = VecDeque::new();
|
||||
let mut cleared: Vec<Pos> = Vec::new();
|
||||
let mut increase: VecDeque<Pos> = VecDeque::new();
|
||||
for p in seeds {
|
||||
scratch.decrease.clear();
|
||||
scratch.cleared.clear();
|
||||
scratch.increase.clear();
|
||||
scratch.sources.clear();
|
||||
|
||||
if seeds.len() > scratch.decrease.capacity() {
|
||||
scratch.decrease.reserve(seeds.len());
|
||||
}
|
||||
if seeds.len() > scratch.cleared.capacity() {
|
||||
scratch.cleared.reserve(seeds.len());
|
||||
}
|
||||
if seeds.len() > scratch.increase.capacity() {
|
||||
scratch.increase.reserve(seeds.len());
|
||||
}
|
||||
|
||||
let decrease = &mut scratch.decrease;
|
||||
let cleared = &mut scratch.cleared;
|
||||
let increase = &mut scratch.increase;
|
||||
for &p in seeds {
|
||||
let Some(old) = level(world, sky, p) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1267,13 +1342,22 @@ fn relight_layer(
|
||||
}
|
||||
}
|
||||
// Sources inside the cleared area light up again.
|
||||
let mut sources: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
let sources = &mut scratch.sources;
|
||||
let mut last_xz = (i32::MIN, i32::MIN);
|
||||
let mut last_lowest = 0;
|
||||
crate::spans::time("lt_sources", || {
|
||||
for p in cleared {
|
||||
for &p in cleared.iter() {
|
||||
let seed = if sky {
|
||||
let lowest = *sources
|
||||
.entry((p.0, p.2))
|
||||
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
let lowest = if (p.0, p.2) == last_xz {
|
||||
last_lowest
|
||||
} else {
|
||||
let lowest = *sources
|
||||
.entry((p.0, p.2))
|
||||
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
last_xz = (p.0, p.2);
|
||||
last_lowest = lowest;
|
||||
lowest
|
||||
};
|
||||
if p.1 >= lowest { 15 } else { 0 }
|
||||
} else {
|
||||
world
|
||||
@@ -2119,9 +2203,39 @@ pub(crate) mod tests {
|
||||
relight(&mut w, &t, true, &positions, false, &mut changed);
|
||||
}
|
||||
println!(
|
||||
"relight_bench: {:?}, {} sections",
|
||||
start.elapsed(),
|
||||
changed.sections().count()
|
||||
"relight_bench (large batch: 400 rounds x 32 blocks): {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn relight_single_block_bench() {
|
||||
let mut w: FxHashMap<_, _> = world(|_| {}).into_iter().collect();
|
||||
let t = tables();
|
||||
let mut rng = 0x2545_f491_u32;
|
||||
let mut next = |n: i32| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
(rng % n as u32) as i32
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
let mut changed = LightChanges::default();
|
||||
for round in 0..2000 {
|
||||
let state = [TORCH, SLAB, AIR][round % 3];
|
||||
let pos = (next(32) - 8, next(12), next(32) - 8);
|
||||
Arc::make_mut(w.get_mut(&(pos.0 >> 4, pos.2 >> 4)).unwrap()).set(
|
||||
pos.0 & 15,
|
||||
pos.1,
|
||||
pos.2 & 15,
|
||||
state,
|
||||
);
|
||||
relight(&mut w, &t, true, &[pos], false, &mut changed);
|
||||
}
|
||||
println!(
|
||||
"relight_single_block_bench (single block: 2000 rounds): {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -466,6 +466,13 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
|
||||
/// serde_json rejects it.
|
||||
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
first_json(bytes)
|
||||
}
|
||||
|
||||
/// Reads the first JSON value from resource metadata, matching Gson's
|
||||
/// `JsonReader` (which ignores a trailing signature or comment). Resource
|
||||
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
|
||||
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
|
||||
.into_iter()
|
||||
.next()?
|
||||
|
||||
@@ -526,6 +526,8 @@ pub struct Particle {
|
||||
no_render: bool,
|
||||
/// Ticked once when created (`CritParticle` constructor).
|
||||
pre_tick: bool,
|
||||
/// A Polytone particle with physics, which joins only where its box is clear of blocks.
|
||||
spawn_check: bool,
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
@@ -895,6 +897,7 @@ impl Particle {
|
||||
behaviour: Behaviour::Base,
|
||||
no_render: false,
|
||||
pre_tick: false,
|
||||
spawn_check: false,
|
||||
frame_ticks: 0,
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
@@ -1759,28 +1762,6 @@ impl ParticleEngine {
|
||||
particle.controller = Some(Box::new(controller));
|
||||
Some(particle)
|
||||
}
|
||||
/// Sets (or clears) the resource packs' Polytone custom particle types.
|
||||
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
|
||||
let registry = registry.filter(|r| !r.is_empty());
|
||||
let same = match (&self.polytone, ®istry) {
|
||||
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
self.polytone_overrides = registry
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
ParticleType::ALL.iter().filter_map(|&ty| {
|
||||
let id = format!("minecraft:{}", ty.name());
|
||||
r.get(&id).is_some().then_some((ty, id))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
|
||||
/// one), for the frame's additive pass.
|
||||
pub fn take_additive(&self) -> Vec<Vertex> {
|
||||
@@ -3621,13 +3602,14 @@ impl ParticleEngine {
|
||||
self.sprite_from_age(&mut q);
|
||||
q
|
||||
}
|
||||
/// ClientLevel.createFireworks: a Starter (or poofs without explosions).
|
||||
/// ClientLevel.createFireworks: a Starter (or poofs without explosions) with `playSound`.
|
||||
pub fn fireworks(
|
||||
&mut self,
|
||||
at: [f64; 3],
|
||||
v: [f64; 3],
|
||||
explosions: Vec<FireworkExplosion>,
|
||||
camera: [f64; 3],
|
||||
play_sound: bool,
|
||||
) {
|
||||
if explosions.is_empty() {
|
||||
for _ in 0..self.random.next_int_bound(3).unwrap_or(0) + 2 {
|
||||
@@ -3651,17 +3633,17 @@ impl ParticleEngine {
|
||||
life: 0,
|
||||
lifetime,
|
||||
twinkle_delay: twinkle,
|
||||
far,
|
||||
sound: play_sound.then_some(far),
|
||||
});
|
||||
}
|
||||
fn tick_starters(&mut self) {
|
||||
for mut s in std::mem::take(&mut self.starters) {
|
||||
if s.life == 0 {
|
||||
if s.life == 0 && s.sound.is_some() {
|
||||
let large = s.explosions.len() >= 3
|
||||
|| s.explosions
|
||||
.iter()
|
||||
.any(|e| e.shape == FireworkShape::LargeBall);
|
||||
let sound = match (large, s.far) {
|
||||
let sound = match (large, s.sound == Some(true)) {
|
||||
(true, true) => "minecraft:entity.firework_rocket.large_blast_far",
|
||||
(true, false) => "minecraft:entity.firework_rocket.large_blast",
|
||||
(false, true) => "minecraft:entity.firework_rocket.blast_far",
|
||||
@@ -3704,16 +3686,13 @@ impl ParticleEngine {
|
||||
(c & 255) as f32 / 255.0,
|
||||
((c >> 24) & 255) as f32 / 255.0,
|
||||
];
|
||||
self.create(
|
||||
&ParticleOptions::colored(ParticleType::Flash, rgba),
|
||||
s.at,
|
||||
[0.0; 3],
|
||||
);
|
||||
let flash = ParticleOptions::colored(ParticleType::Flash, rgba);
|
||||
self.create(&flash, s.at, [0.0; 3]);
|
||||
}
|
||||
s.life += 1;
|
||||
if s.life > s.lifetime {
|
||||
if s.twinkle_delay {
|
||||
let sound = if s.far {
|
||||
if s.twinkle_delay && s.sound.is_some() {
|
||||
let sound = if s.sound == Some(true) {
|
||||
"minecraft:entity.firework_rocket.twinkle_far"
|
||||
} else {
|
||||
"minecraft:entity.firework_rocket.twinkle"
|
||||
@@ -3846,6 +3825,9 @@ impl ParticleEngine {
|
||||
}
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
for mut p in pending {
|
||||
if p.spawn_check && !polytone::unobstructed(level, p.bb) {
|
||||
continue;
|
||||
}
|
||||
if p.pre_tick {
|
||||
p.pre_tick = false;
|
||||
self.tick_particle(&mut p, level);
|
||||
@@ -5054,7 +5036,9 @@ struct Starter {
|
||||
life: i32,
|
||||
lifetime: i32,
|
||||
twinkle_delay: bool,
|
||||
far: bool,
|
||||
/// `FireworkParticles.Starter.playSound`, with `isFarAwayFromCamera` when
|
||||
/// it plays: `None` for a silent rocket.
|
||||
sound: Option<bool>,
|
||||
}
|
||||
const STAR: [[f64; 2]; 6] = [
|
||||
[0.0, 1.0],
|
||||
|
||||
@@ -1090,6 +1090,16 @@ const INITIALIZED: [&str; 13] = [
|
||||
"dz",
|
||||
];
|
||||
|
||||
/// Whether no block's collision shape overlaps `bb` (where the query fails, nothing does).
|
||||
pub(super) fn unobstructed(level: &ParticleLevel<'_>, bb: horizon_core::geometry::Aabb) -> bool {
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
scene.cells_without_collision(bb) || scene.unobstructed(bb).unwrap_or(true)
|
||||
}
|
||||
|
||||
impl super::ParticleController for Controller {
|
||||
fn tick(&mut self, p: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
let mut update = ParticleUpdate::default();
|
||||
@@ -1320,6 +1330,10 @@ pub(super) fn make(
|
||||
particle.polytone_type =
|
||||
(def.limit.is_some() || def.exclusion_radius > 0.0).then(|| Arc::from(name));
|
||||
particle.bb = super::aabb_at(position, particle.bb_width, particle.bb_height);
|
||||
// A particle with physics whose box starts inside a block's collision never joins the world:
|
||||
// it is not drawn and none of its emitters run (measured on the mod: one spawned just under a
|
||||
// block, where falling leaves start, never shows; one 0.01 clear of it does).
|
||||
particle.spawn_check = def.has_physics;
|
||||
particle.controller = Some(Box::new(Controller {
|
||||
def: def.clone(),
|
||||
registry: registry.clone(),
|
||||
@@ -1531,6 +1545,31 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleEngine {
|
||||
/// Sets (or clears) the resource packs' Polytone custom particle types.
|
||||
pub fn set_polytone(&mut self, registry: Option<Arc<Registry>>) {
|
||||
let registry = registry.filter(|r| !r.is_empty());
|
||||
let same = match (&self.polytone, ®istry) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
self.polytone_overrides = registry
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
ParticleType::ALL.iter().filter_map(|&ty| {
|
||||
let id = format!("minecraft:{}", ty.name());
|
||||
r.get(&id).is_some().then_some((ty, id))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2244,6 +2283,144 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `scene`, with the torch a full block of collision: something a particle's box can start in.
|
||||
fn solid_scene(blocks: &'static BlockRegistry) -> Scene {
|
||||
use horizon_core::geometry::{Axis, VoxelShape};
|
||||
let tag = |shape: VoxelShape| {
|
||||
let sizes = shape.sizes();
|
||||
let occupied = (0..sizes[2])
|
||||
.flat_map(|z| {
|
||||
(0..sizes[1]).flat_map(move |y| (0..sizes[0]).map(move |x| (x, y, z)))
|
||||
})
|
||||
.map(|(x, y, z)| u8::from(shape.is_full(x as i32, y as i32, z as i32)))
|
||||
.collect();
|
||||
let (min, max) = shape.discrete_bounds();
|
||||
let axes = shape
|
||||
.cube_coordinate_axes()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0, |mask, (i, on)| mask | (i8::from(*on) << i));
|
||||
let mut fields = vec![
|
||||
("occupied", Tag::ByteArray(occupied)),
|
||||
("cube", Tag::Byte(i8::from(shape.is_cube()))),
|
||||
("cube_axes", Tag::Byte(axes)),
|
||||
("discrete_min", Tag::IntArray(min.to_vec())),
|
||||
("discrete_max", Tag::IntArray(max.to_vec())),
|
||||
];
|
||||
for (axis, name) in [(Axis::X, "x"), (Axis::Y, "y"), (Axis::Z, "z")] {
|
||||
let bits = shape.coordinates(axis).iter().map(|v| v.to_bits() as i64);
|
||||
fields.push((name, Tag::LongArray(bits.collect())));
|
||||
}
|
||||
Tag::compound(fields)
|
||||
};
|
||||
let names = || Tag::List(STATES.iter().map(|s| Tag::String((*s).into())).collect());
|
||||
let cached = CachedCollisionRegistry::from_nbt(
|
||||
&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("states", names()),
|
||||
(
|
||||
"shapes",
|
||||
Tag::List(vec![tag(VoxelShape::empty()), tag(VoxelShape::block())]),
|
||||
),
|
||||
("cached_shapes", Tag::IntArray(vec![0, 1])),
|
||||
]),
|
||||
blocks,
|
||||
)
|
||||
.unwrap();
|
||||
Scene {
|
||||
collision: BlockCollisionRegistry::new(Arc::new(cached)).unwrap(),
|
||||
..scene(blocks)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_particle_with_physics_born_inside_a_block_never_joins() {
|
||||
let blocks = blocks();
|
||||
let leaf = |physics| {
|
||||
serde_json::json!({"has_physics": physics, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "20", "hitbox_size": "0.08"},
|
||||
"particle_emitters": [{"particle": "test:mark", "chance": "1"}]})
|
||||
};
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/leaf.json",
|
||||
leaf(true),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/ghost.json",
|
||||
leaf(false),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/mark.json",
|
||||
serde_json::json!({"render_type": "invisible", "has_physics": false}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:leaf", "chance": "1"}]}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/wide.json",
|
||||
serde_json::json!({"has_physics": true, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "20", "hitbox_size": 10},
|
||||
"particle_emitters": [{"particle": "test:mark", "chance": "1"}]}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/parent.json",
|
||||
serde_json::json!({"has_physics": true, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "20", "hitbox_size": "0.08"},
|
||||
"particle_emitters": [{"particle": "test:wide", "chance": "1", "y": "-0.05"}]}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/skeleton.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:parent", "chance": "1", "x": "-4.5"}]}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/creeper.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:wide", "chance": "1", "x": "-4.5"}]}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/zombie.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:ghost", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = solid_scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// The solid block is at (1, 64, 1): its bottom face is y 64.
|
||||
// Spawns one particle, lets the engine take it in and tick it: whether it joined, and
|
||||
// how many particles it emitted.
|
||||
let mut spawn = |entity: &str, y: f64| {
|
||||
engine.particles.clear();
|
||||
engine.pending.clear();
|
||||
engine.polytone_entity(&level, entity, [1.5, y, 1.5]);
|
||||
engine.tick(&level);
|
||||
let joined = engine.particles.len();
|
||||
engine.tick(&level);
|
||||
(
|
||||
joined,
|
||||
engine.particles.len() + engine.pending.len() - joined,
|
||||
)
|
||||
};
|
||||
// The solid block is at (1, 64, 1): its bottom face is y 64.
|
||||
// 0.05 under the block, a 0.08 box reaches into it: it never joins, nothing emitted.
|
||||
assert_eq!(spawn("minecraft:player", 63.95), (0, 0));
|
||||
// 0.01 clear of it, it joins and emits.
|
||||
assert_eq!(spawn("minecraft:player", 63.91), (1, 1));
|
||||
// Without physics nothing collides.
|
||||
assert_eq!(spawn("minecraft:zombie", 63.95), (1, 1));
|
||||
// A wide box reaching a block from five blocks away does not join either.
|
||||
assert_eq!(spawn("minecraft:creeper", 60.0), (0, 0));
|
||||
// Nor does one a clear particle emits there: the small one joins, its wide copy never.
|
||||
assert_eq!(spawn("minecraft:skeleton", 60.0), (1, 0));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
|
||||
let root = pack(&[(
|
||||
|
||||
@@ -43,7 +43,7 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
|
||||
twinkle: false,
|
||||
};
|
||||
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
|
||||
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
|
||||
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera, true);
|
||||
}
|
||||
for &(ty, count, center, delta, speed) in types {
|
||||
let options = ParticleOptions::simple(ty);
|
||||
|
||||
@@ -7,13 +7,16 @@ use horizon_core::{
|
||||
geometry::{Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::collision_context::CollisionContext;
|
||||
|
||||
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
|
||||
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
|
||||
|
||||
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
|
||||
/// with no collision shape.
|
||||
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
/// with no collision shape for `CollisionContext.positionContext(context_y)`.
|
||||
/// Vanilla passes the height of the block under the column it reads, so
|
||||
/// scaffolding seen from below lets the gas and the geyser through.
|
||||
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3], context_y: i32) -> bool {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
|
||||
return true;
|
||||
@@ -23,7 +26,9 @@ pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
match level.collision.collision_shape(&mut scene, state, pos) {
|
||||
let context = CollisionContext::position(f64::from(context_y));
|
||||
let position = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
match (level.collision).collision_shape_with_context(&mut scene, state, position, &context) {
|
||||
Ok(shape) => shape.is_empty(),
|
||||
Err(_) => !level.facts.get(state).full_collision,
|
||||
}
|
||||
@@ -46,9 +51,9 @@ pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) ->
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
|
||||
if !is_water_source(level, pos)
|
||||
|| (!is_water_block && !is_geyser_passable_block(level, pos))
|
||||
|| (!is_water_block && !is_geyser_passable_block(level, pos, origin[1]))
|
||||
{
|
||||
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
|
||||
if level.facts.is_air(state) || is_geyser_passable_block(level, pos, origin[1]) {
|
||||
return Some(pos);
|
||||
}
|
||||
break;
|
||||
@@ -63,7 +68,7 @@ pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) ->
|
||||
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
|
||||
let geyser_force_height = 6 * water_blocks;
|
||||
for i in 0..geyser_force_height {
|
||||
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
|
||||
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]], pos[1] - 1) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +84,7 @@ pub fn can_be_reached_by_noxious_gas(
|
||||
pos: [f64; 3],
|
||||
) -> bool {
|
||||
let block = ParticleLevel::block_at(pos);
|
||||
if !is_geyser_passable_block(level, block) {
|
||||
if !is_geyser_passable_block(level, block, block[1] - 1) {
|
||||
return false;
|
||||
}
|
||||
let center = source_block.map(|c| f64::from(c) + 0.5);
|
||||
|
||||
@@ -232,6 +232,8 @@ impl Selector {
|
||||
pub struct Tick {
|
||||
/// A vibration was chosen: its travel time (the particle starts).
|
||||
pub started: Option<(Info, i32)>,
|
||||
/// A vibration was reloaded from save: particle resumes from interpolated pos.
|
||||
pub resumed: Option<(Info, i32)>,
|
||||
/// A vibration arrived.
|
||||
pub received: Option<Info>,
|
||||
/// `onDataChanged`.
|
||||
@@ -261,6 +263,10 @@ impl Data {
|
||||
self.selector.start_over();
|
||||
}
|
||||
if let Some(info) = self.current {
|
||||
if self.reload_particle {
|
||||
self.reload_particle = false;
|
||||
out.resumed = Some((info, self.travel));
|
||||
}
|
||||
let mut changed = self.travel > 0;
|
||||
self.travel = (self.travel - 1).max(0);
|
||||
if self.travel <= 0 {
|
||||
@@ -293,8 +299,11 @@ impl Data {
|
||||
Tag::compound(entries)
|
||||
}
|
||||
pub fn from_nbt(tag: &Tag) -> Self {
|
||||
let current = tag.get("event").and_then(info_from_nbt);
|
||||
let reload_particle = current.is_some();
|
||||
let mut data = Self {
|
||||
current: tag.get("event").and_then(info_from_nbt),
|
||||
current,
|
||||
reload_particle,
|
||||
..Self::default()
|
||||
};
|
||||
if let Some(Tag::Int(t)) = tag.get("event_delay") {
|
||||
@@ -979,4 +988,30 @@ mod tests {
|
||||
assert_eq!(back.spreader.cursors[0].charge, 1000);
|
||||
assert_eq!(back.spreader.cursors[1].charge, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vibration_reloads_particle() {
|
||||
let tag = Tag::compound([
|
||||
(
|
||||
"event",
|
||||
Tag::compound([
|
||||
("game_event", Tag::String("minecraft:step".into())),
|
||||
("distance", Tag::Float(4.0f32.to_bits())),
|
||||
(
|
||||
"pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(1.0f64.to_bits()),
|
||||
Tag::Double(0),
|
||||
Tag::Double(0),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
("event_delay", Tag::Int(3)),
|
||||
]);
|
||||
let mut d = Data::from_nbt(&tag);
|
||||
assert!(d.reload_particle && d.current.is_some());
|
||||
let tick = d.tick(10, true, true, |dist| dist as i32);
|
||||
assert!(tick.resumed.is_some() && !d.reload_particle);
|
||||
}
|
||||
}
|
||||
@@ -965,13 +965,13 @@ const PI: f32 = 3.14159265;
|
||||
fn disc_distance(d: vec3<f32>, y: f32) -> vec2<f32> {
|
||||
if d.y * y <= 0.0 { return vec2(-1.0); }
|
||||
let p = d * (y / d.y);
|
||||
let h = length(p.xz);
|
||||
let phi = atan2(p.z, p.x);
|
||||
let sector = PI / 4.0;
|
||||
let delta = phi - (floor(phi / sector) * sector + sector * 0.5);
|
||||
let edge = 512.0 * cos(sector * 0.5) / cos(delta);
|
||||
if h > edge { return vec2(-1.0); }
|
||||
let s = h / edge;
|
||||
// The regular octagon's edge parameter, directly from its half planes.
|
||||
// max(|x| + tan(pi/8)*|z|, |z| + tan(pi/8)*|x|) / radius
|
||||
// equals length(p.xz) / edge without atan2, floor or cos per pixel.
|
||||
let a = abs(p.xz);
|
||||
let diagonal = 0.41421356237309503;
|
||||
let s = max(a.x + diagonal * a.y, a.y + diagonal * a.x) / 512.0;
|
||||
if s > 1.0 { return vec2(-1.0); }
|
||||
let ay = abs(y);
|
||||
let rim = sqrt(512.0 * 512.0 + y * y);
|
||||
return vec2(ay + s * (rim - ay), ay + s * (512.0 - ay));
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum Cursor {
|
||||
Arrow,
|
||||
PointingHand,
|
||||
NotAllowed,
|
||||
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
|
||||
ResizeNs,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
||||
@@ -1208,6 +1208,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -274,8 +274,8 @@ impl ArrowState {
|
||||
/// `Arrow.getColor` of the server's own arrow: the colour of the potion on
|
||||
/// its pickup item, None for a plain arrow (`PotionContents.EMPTY`).
|
||||
pub fn potion_color(&self) -> Option<u32> {
|
||||
let potion = self.pickup_item.potion()?;
|
||||
Some(crate::brewing::potion_color(Some(potion)))
|
||||
(!crate::brewing::contents_empty(&self.pickup_item))
|
||||
.then(|| crate::brewing::contents_color(&self.pickup_item))
|
||||
}
|
||||
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
|
||||
/// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`)
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod bed_geometry;
|
||||
pub mod block_contact;
|
||||
pub mod parallel;
|
||||
pub mod powder_snow;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, autotune, ban_notice, bidi, biome, block_edit,
|
||||
@@ -85,6 +86,7 @@ pub mod join_seq;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod legacy_combat;
|
||||
pub mod living_death;
|
||||
pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
|
||||
@@ -137,6 +137,15 @@ pub struct DebugKeys {
|
||||
}
|
||||
|
||||
impl DebugKeys {
|
||||
/// Window focus lost, or input reset: clear held modifier state.
|
||||
pub fn reset(&mut self) {
|
||||
self.modifier_down = false;
|
||||
self.used_debug_key_as_modifier = false;
|
||||
if self.crash_key_time > 0 {
|
||||
self.crash_key_time = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `keyPress` once the open screen (if any) did not take the key. `key`
|
||||
/// is the physical key's name (`key.keyboard.f3`); `crash_key_down`
|
||||
/// whether the crash mapping's key is held (`InputConstants.isKeyDown`).
|
||||
@@ -487,4 +496,18 @@ mod tests {
|
||||
press(&mut k, &mut h, &b, "key.keyboard.k", 2);
|
||||
assert_eq!(h.actions, [(DebugAction::ShowChunkBorders, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_on_focus_loss_clears_held_modifier_and_avoids_spurious_shortcuts() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
assert!(k.modifier_down);
|
||||
// Window loses focus while F3 was down (e.g. desktop shortcut Ctrl+F3)
|
||||
k.reset();
|
||||
assert!(!k.modifier_down);
|
||||
assert!(!k.used_debug_key_as_modifier);
|
||||
// User refocuses and presses 'b': it should NOT trigger ShowHitboxes
|
||||
assert!(press(&mut k, &mut h, &b, "key.keyboard.b", 1));
|
||||
assert!(h.actions.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,9 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
mod obstacles;
|
||||
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
|
||||
pub use obstacles::ObstacleGrid;
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_motion_tests {
|
||||
@@ -1111,110 +1113,6 @@ fn lava_cauldron_contacts(
|
||||
}
|
||||
contacts
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
/// Each box's entity's `getRootVehicle` when it rides something; empty
|
||||
/// when none was given.
|
||||
roots: Vec<Option<u32>>,
|
||||
found: Vec<u32>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
Self::with_roots(boxes, Vec::new())
|
||||
}
|
||||
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
|
||||
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
|
||||
/// mover's own riders out.
|
||||
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
|
||||
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
cells,
|
||||
boxes,
|
||||
roots,
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
self.near_except(area, None, out);
|
||||
}
|
||||
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
|
||||
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
|
||||
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
|
||||
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
|
||||
let (lo, hi) = Self::span(area);
|
||||
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
|
||||
let cells = side(lo.0, hi.0)
|
||||
.saturating_mul(side(lo.1, hi.1))
|
||||
.saturating_mul(side(lo.2, hi.2));
|
||||
// An area over more cells than there are boxes (a stand loaded with a
|
||||
// huge Motion asks for one millions of blocks long): test each box
|
||||
// once rather than walk the cells.
|
||||
if cells > self.boxes.len() as i64 {
|
||||
out.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, b)| kept(i) && b.intersects(area))
|
||||
.map(|(_, b)| *b),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.found.clear();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
self.found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.found.sort_unstable();
|
||||
self.found.dedup();
|
||||
for &i in &self.found {
|
||||
let b = self.boxes[i as usize];
|
||||
if kept(i as usize) && b.intersects(area) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
|
||||
/// search around an item. Holds indexes into the tick's item list.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The boxes that stop a dropped item, by cell (`ObstacleGrid`).
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
/// Each box's entity's `getRootVehicle` when it rides something; empty
|
||||
/// when none was given.
|
||||
roots: Vec<Option<u32>>,
|
||||
found: Vec<u32>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
Self::with_roots(boxes, Vec::new())
|
||||
}
|
||||
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
|
||||
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
|
||||
/// mover's own riders out.
|
||||
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
|
||||
let mut cells: FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
cells,
|
||||
boxes,
|
||||
roots,
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
self.near_except(area, None, out);
|
||||
}
|
||||
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
|
||||
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
|
||||
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
|
||||
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
|
||||
let mut found = std::mem::take(&mut self.found);
|
||||
self.near_with(area, vehicle, &mut found, out);
|
||||
self.found = found;
|
||||
}
|
||||
/// [`Self::near`] with the caller's scratch, so several threads can ask one grid.
|
||||
pub fn near_shared(&self, area: Aabb, found: &mut Vec<u32>, out: &mut Vec<Aabb>) {
|
||||
self.near_with(area, None, found, out);
|
||||
}
|
||||
fn near_with(
|
||||
&self,
|
||||
area: Aabb,
|
||||
vehicle: Option<u32>,
|
||||
found: &mut Vec<u32>,
|
||||
out: &mut Vec<Aabb>,
|
||||
) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
|
||||
let (lo, hi) = Self::span(area);
|
||||
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
|
||||
let cells = side(lo.0, hi.0)
|
||||
.saturating_mul(side(lo.1, hi.1))
|
||||
.saturating_mul(side(lo.2, hi.2));
|
||||
// An area over more cells than there are boxes (a stand loaded with a
|
||||
// huge Motion asks for one millions of blocks long): test each box
|
||||
// once rather than walk the cells.
|
||||
if cells > self.boxes.len() as i64 {
|
||||
out.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, b)| kept(i) && b.intersects(area))
|
||||
.map(|(_, b)| *b),
|
||||
);
|
||||
return;
|
||||
}
|
||||
found.clear();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
for &i in found.iter() {
|
||||
let b = self.boxes[i as usize];
|
||||
if kept(i as usize) && b.intersects(area) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
horizon_hooks::engine::seal();
|
||||
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
|
||||
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -230,6 +232,14 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
living_jump: |entity, kind| {
|
||||
crate::mod_api::dispatch_living_jump(crate::mod_api::mods(), entity, kind);
|
||||
},
|
||||
living_death_message: |entity, kind, message| {
|
||||
crate::mod_api::dispatch_living_death_message(
|
||||
crate::mod_api::mods(),
|
||||
entity,
|
||||
kind,
|
||||
message,
|
||||
);
|
||||
},
|
||||
register_goals: |mob| {
|
||||
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
|
||||
},
|
||||
|
||||
@@ -1505,7 +1505,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
{
|
||||
return signal;
|
||||
}
|
||||
machines::container_signal(self.world, self.behaviours, self.items, self.registry, pos)
|
||||
machines::container_signal(self, pos)
|
||||
}
|
||||
fn item_frame_analog_output(&self, pos: BlockPos, facing: Direction) -> Option<i32> {
|
||||
let mut frames = self
|
||||
@@ -2328,6 +2328,8 @@ struct Game<'a> {
|
||||
last_device_recovery: Option<Instant>,
|
||||
/// CPU geometry storage retained across frames, including empty/title frames.
|
||||
entity_vertices: Vec<Vertex>,
|
||||
/// Falling models by state and selected-model address list; cleared on pack/atlas replacement.
|
||||
falling_vertices: RefCell<HashMap<BlockStateId, Vec<(Vec<usize>, Vec<Vertex>)>>>,
|
||||
/// Horizon Extras' dynamic lights this frame (`dynamic_lights.rs`), and how brightly each
|
||||
/// item glows (built the first time the extra is on).
|
||||
dynamic_lights: Vec<[f32; 4]>,
|
||||
@@ -5033,8 +5035,8 @@ impl Game<'_> {
|
||||
self.loot_block_entity(pos, &facts.name)
|
||||
};
|
||||
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
|
||||
let name = self.world.block_names.get(&key).cloned();
|
||||
self.pending.record(pos, &entity, &facts.name, name);
|
||||
self.pending
|
||||
.record_from(&self.world, pos, &entity, &facts.name);
|
||||
} else if let Some(tag) = spill::saved_entity(&self.world, pos) {
|
||||
self.pending.record_saved(pos, &tag);
|
||||
}
|
||||
@@ -7158,14 +7160,7 @@ impl Game<'_> {
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let start = out.len();
|
||||
self.mark_motion(start, Self::MOTION_OBJECT | u64::from(f.id));
|
||||
self.block_model_vertices(
|
||||
f.state,
|
||||
horizon_core::random::position_seed(f.start_pos),
|
||||
glam::Mat4::from_translation(o),
|
||||
packed,
|
||||
false,
|
||||
&mut out,
|
||||
);
|
||||
self.falling_model_vertices(f.state, f.start_pos, o, packed, &mut out);
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
|
||||
@@ -8211,10 +8206,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(kind) = mob {
|
||||
if let Some(id) = self.spawn_mob_at(kind, (target[0], target[1], target[2]), true) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.mob_placed(id)?;
|
||||
self.mob_placed(id, stack)?;
|
||||
}
|
||||
self.post_block_game_event(
|
||||
"minecraft:entity_place",
|
||||
@@ -9972,20 +9964,10 @@ impl Game<'_> {
|
||||
self.drag_button = None;
|
||||
}
|
||||
if let WindowEvent::ModifiersChanged(m) = &event {
|
||||
self.modifiers = m.state();
|
||||
horizon_client_input::modifiers::reconcile(
|
||||
self.modifiers,
|
||||
&mut self.physical_keys,
|
||||
&self.settings.keys,
|
||||
&mut self.toggles,
|
||||
[self.settings.toggle_crouch, self.settings.toggle_sprint],
|
||||
[
|
||||
self.pad.holds_action("key.sneak"),
|
||||
self.pad.holds_action("key.sprint"),
|
||||
],
|
||||
);
|
||||
self.sync_toggle(SNEAK);
|
||||
self.sync_toggle(SPRINT);
|
||||
self.update_modifiers(m.state());
|
||||
}
|
||||
if let WindowEvent::Focused(false) = &event {
|
||||
self.update_modifiers(winit::keyboard::ModifiersState::empty());
|
||||
}
|
||||
// `PackSelectionScreen.onFilesDrop` on the Create New World screen.
|
||||
if let WindowEvent::DroppedFile(path) = &event
|
||||
@@ -10391,6 +10373,8 @@ impl Game<'_> {
|
||||
let held = self.captured;
|
||||
if self.control.is_none() {
|
||||
self.keys.clear();
|
||||
self.modifiers = winit::keyboard::ModifiersState::empty();
|
||||
self.debug_keys.reset();
|
||||
// Latched sneak/sprint live in `toggles`, not in the
|
||||
// physical keys: put them back so a short alt-tab
|
||||
// (no pause screen) does not drop them. Only a mapping
|
||||
@@ -10398,21 +10382,18 @@ impl Game<'_> {
|
||||
// no state to keep, and an unfocused window never gets
|
||||
// its key release, so leaving it down would keep the
|
||||
// player sneaking or sprinting until the key is tapped.
|
||||
for (slot, needs_toggle) in [
|
||||
let toggles = [
|
||||
self.settings.toggle_crouch,
|
||||
self.settings.toggle_sprint,
|
||||
self.settings.toggle_attack,
|
||||
self.settings.toggle_use,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
];
|
||||
for (slot, needs_toggle) in toggles.into_iter().enumerate() {
|
||||
self.toggles.0[slot].set_down(false, needs_toggle);
|
||||
}
|
||||
self.sync_toggle(0);
|
||||
self.sync_toggle(1);
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
(self.left, self.right) = (false, false);
|
||||
self.capture(false);
|
||||
}
|
||||
self.regrab_on_refocus = held;
|
||||
@@ -11685,6 +11666,7 @@ impl<'a> Game<'a> {
|
||||
renderer: None,
|
||||
last_device_recovery: None,
|
||||
entity_vertices: Vec::new(),
|
||||
falling_vertices: Default::default(),
|
||||
dynamic_lights: Vec::new(),
|
||||
luminance: Default::default(),
|
||||
see_through_vertices: Vec::new(),
|
||||
|
||||
@@ -230,15 +230,10 @@ impl adv::World for View<'_, '_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Level.canSeeSky`: sky light 15 at the position, whatever stands
|
||||
/// above it, as `MobWorld` reads it for the spawn rules.
|
||||
fn can_see_sky(&self, p: [i32; 3]) -> bool {
|
||||
(p[1] + 1..p[1] + 200).all(|y| {
|
||||
!self
|
||||
.0
|
||||
.res
|
||||
.facts()
|
||||
.get(self.0.world.block(p[0], y, p[2]))
|
||||
.full_collision
|
||||
})
|
||||
self.0.mob_world_base(&[]).can_see_sky(p[0], p[1], p[2])
|
||||
}
|
||||
fn light(&self, p: [i32; 3]) -> i32 {
|
||||
let l = self.0.world.light(p[0], p[1], p[2]);
|
||||
|
||||
@@ -153,7 +153,18 @@ impl Game<'_> {
|
||||
.shulkers
|
||||
.get_mut(&pos)
|
||||
.map(|shulker| shulker.tick(pos, facing));
|
||||
if let Some(push) = tick.and_then(|tick| tick.push) {
|
||||
let Some(tick) = tick else { continue };
|
||||
// `ShulkerBoxBlockEntity.doNeighborUpdates` at the start and the
|
||||
// end of an opening or closing lid: the box's collision shape
|
||||
// moved, so the neighbours' shapes and their redstone updates run.
|
||||
for _ in 0..tick.neighbor_updates {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.shulker_neighbor_updates(level, ticks, at, state)
|
||||
});
|
||||
}
|
||||
if let Some(push) = tick.push {
|
||||
pushes.push((pos, facing, push));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,9 @@ fn clone_item<'a>(name: &str, block_item: &'a str) -> &'a str {
|
||||
"minecraft:kelp_plant" => "minecraft:kelp",
|
||||
"minecraft:twisting_vines_plant" => "minecraft:twisting_vines",
|
||||
"minecraft:weeping_vines_plant" => "minecraft:weeping_vines",
|
||||
// CandleCakeBlock: new ItemStack(Blocks.CAKE), for every colour.
|
||||
_ if name.ends_with("_candle_cake") => "minecraft:cake",
|
||||
// CandleCakeBlock: new ItemStack(Blocks.CAKE), for every colour and
|
||||
// the plain `minecraft:candle_cake`, whose own item is air.
|
||||
_ if name == "minecraft:candle_cake" || name.ends_with("_candle_cake") => "minecraft:cake",
|
||||
_ => block_item,
|
||||
}
|
||||
}
|
||||
@@ -424,6 +425,7 @@ mod tests {
|
||||
("minecraft:kelp_plant", "minecraft:kelp"),
|
||||
("minecraft:twisting_vines_plant", "minecraft:twisting_vines"),
|
||||
("minecraft:weeping_vines_plant", "minecraft:weeping_vines"),
|
||||
("minecraft:candle_cake", "minecraft:cake"),
|
||||
("minecraft:white_candle_cake", "minecraft:cake"),
|
||||
("minecraft:light_blue_candle_cake", "minecraft:cake"),
|
||||
] {
|
||||
|
||||
@@ -239,6 +239,19 @@ impl Game<'_> {
|
||||
self.tick_scheduled()?;
|
||||
}
|
||||
self.with_level(|b, level, ticks| {
|
||||
// The masks the random ticks will read, built on helper threads first while the
|
||||
// parallel tick is on (`HORIZON_PARALLEL_TICK`): the draws and ticks don't depend on them.
|
||||
if !debug && horizon_client::parallel::lanes() > 1 {
|
||||
horizon_client::spans::time("random_tick_masks", || {
|
||||
let chunks = &level.world.chunks;
|
||||
level.random_tick_sections.prepare_masks(
|
||||
&scratch.ticking,
|
||||
|key| chunks.get(&key).cloned(),
|
||||
level.facts,
|
||||
&|id| b.random_tick_candidate(BlockStateId(id)),
|
||||
);
|
||||
});
|
||||
}
|
||||
horizon_client::spans::time("random_ticks", || {
|
||||
for (x, z) in scratch.ticking.drain(..).filter(|_| !debug) {
|
||||
// The last answer, asked from Y `from` at a world revision:
|
||||
|
||||
@@ -682,19 +682,14 @@ impl Game<'_> {
|
||||
/// of it (`ClipContext.Block.FALLDAMAGE_RESETTING`, `ClipContext.Fluid.WATER`)
|
||||
/// and a hit resets the fall, before the move's own drop counts.
|
||||
pub(super) fn vehicle_fall_reset(&self, fall: f64, from: Vec3, moved: Vec3) -> f64 {
|
||||
let length = moved.length_squared();
|
||||
if fall == 0.0 || length < 1.0 {
|
||||
return fall;
|
||||
}
|
||||
let reset = &self.res.motion().fall_reset;
|
||||
let hit = horizon_client::entities::fall_reset_hit(
|
||||
fall_reset(
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
|
||||
self.res.motion(),
|
||||
self.res.facts(),
|
||||
fall,
|
||||
from,
|
||||
moved,
|
||||
);
|
||||
if hit { 0.0 } else { fall }
|
||||
)
|
||||
}
|
||||
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
|
||||
/// `b` already where it landed): on the ground the fall resets; a move
|
||||
@@ -2082,3 +2077,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
horizon_client::impl_joined!(Boat);
|
||||
|
||||
/// [`Game::vehicle_fall_reset`] from the tables it reads.
|
||||
pub(super) fn fall_reset(
|
||||
world: &horizon_client::world::World,
|
||||
motion: &horizon_client::motion::MotionResources,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
fall: f64,
|
||||
from: Vec3,
|
||||
moved: Vec3,
|
||||
) -> f64 {
|
||||
if fall == 0.0 || moved.length_squared() < 1.0 {
|
||||
return fall;
|
||||
}
|
||||
let reset = &motion.fall_reset;
|
||||
let hit = horizon_client::entities::fall_reset_hit(
|
||||
world,
|
||||
&motion.fluids,
|
||||
|p| reset.contains(&*facts.get(world.block(p.x, p.y, p.z)).name),
|
||||
from,
|
||||
moved,
|
||||
);
|
||||
if hit { 0.0 } else { fall }
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! pixels, 1024 characters, 100 pages), signing it into a written book,
|
||||
//! reading written books, and lecterns (placing a book, turning its pages
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::{Game, lan::LanHost, screen_info};
|
||||
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use horizon_world::nbt::Tag;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -213,6 +214,15 @@ impl Game<'_> {
|
||||
"minecraft:written_book" => true,
|
||||
_ => return false,
|
||||
};
|
||||
// `ServerPlayer.openItemGui` resolves a written book for the player
|
||||
// opening it before the screen reads it.
|
||||
let held;
|
||||
let stack = if written && self.resolve_held_book() {
|
||||
held = self.held().cloned();
|
||||
held.as_ref().unwrap_or(stack)
|
||||
} else {
|
||||
stack
|
||||
};
|
||||
if self.lan_acting.is_some() {
|
||||
if written {
|
||||
let hand = i32::from(self.hand == Hand::Off);
|
||||
@@ -227,6 +237,56 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `ServerPlayer.openItemGui` -> `WrittenBookContent.resolveForItem`: the
|
||||
/// selectors and scores of the written book in the acting hand become
|
||||
/// their text for the player opening it, and the book is marked resolved
|
||||
/// (so it is never resolved again). True when the pages were rewritten.
|
||||
fn resolve_held_book(&mut self) -> bool {
|
||||
let Some(mut book) = self
|
||||
.held()
|
||||
.and_then(|s| s.data())
|
||||
.and_then(|d| d.written.clone())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if book.resolved {
|
||||
return false;
|
||||
}
|
||||
let string = |v: &Tag| match v {
|
||||
Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let changed = book.resolve(|tag| {
|
||||
let Tag::Compound(c) = tag else { return None };
|
||||
if let Some(Tag::String(selector)) = c.get(&"selector".into()) {
|
||||
let selector = selector.to_string_lossy();
|
||||
return Some(self.resolve_selector_text(&selector));
|
||||
}
|
||||
let score = c.get(&"score".into())?;
|
||||
let name = score.get("name").and_then(string).unwrap_or_default();
|
||||
let objective = score.get("objective").and_then(string).unwrap_or_default();
|
||||
let name = if name == "*" {
|
||||
PLAYER_NAME
|
||||
} else {
|
||||
name.as_str()
|
||||
};
|
||||
Some(
|
||||
self.score
|
||||
.board
|
||||
.score(name, &objective)
|
||||
.map_or_else(String::new, |s| s.value.to_string()),
|
||||
)
|
||||
});
|
||||
if let Some(stack) = self.held_mut() {
|
||||
stack.data_mut().written = Some(book);
|
||||
}
|
||||
// A guest's copy of the book lives in its mirrored inventory: the
|
||||
// host sends the resolved slot back with the rest of the inventory.
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_inventory_resend = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// The screen a book in hand opens: `BookEditScreen` for a book and
|
||||
/// quill, `BookViewScreen` for a written book.
|
||||
fn book_screen(&self, stack: &ItemStack) -> Option<BookScreen> {
|
||||
@@ -349,6 +409,7 @@ impl Game<'_> {
|
||||
generation: 0,
|
||||
pages,
|
||||
page_tags: Vec::new(),
|
||||
resolved: false,
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
|
||||
@@ -23,3 +23,34 @@ fn breeding_drops_one_orb_of_the_whole_roll() {
|
||||
}
|
||||
assert!(values.iter().any(|v| matches!(v, 4..=6)), "{values:?}");
|
||||
}
|
||||
|
||||
/// `Turtle.TurtleBreedGoal.breed` overrides `Animal.breed`, so a bred turtle
|
||||
/// never runs `finalizeSpawnChildFromBreeding`'s heart burst (entity event
|
||||
/// 18); `Frog.spawnChildFromBreeding` still does.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_bred_turtle_posts_no_heart_burst_but_a_bred_frog_does() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
game.audio.capture = Some(Vec::new());
|
||||
for (kind_id, hearts) in [("minecraft:turtle", 0), ("minecraft:frog", 1)] {
|
||||
let kind = game.res.mob_types().get(kind_id).unwrap().clone();
|
||||
game.mobs
|
||||
.push(Mob::new(1, kind.clone(), Vec3::new(8.5, 64.0, 8.5), 0.0));
|
||||
game.mobs
|
||||
.push(Mob::new(2, kind, Vec3::new(9.5, 64.0, 8.5), 0.0));
|
||||
game.lan_entity_events.clear();
|
||||
game.breed(1, 2, None);
|
||||
let burst = game
|
||||
.lan_entity_events
|
||||
.iter()
|
||||
.filter(|(id, event, _)| *id == 1 && *event == 18)
|
||||
.count();
|
||||
assert_eq!(
|
||||
burst, hearts,
|
||||
"{kind_id} heartbeat events: {:?}",
|
||||
game.lan_entity_events
|
||||
);
|
||||
game.mobs.clear();
|
||||
game.orbs.clear();
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
|
||||
items::ItemStack, mesher::Vertex, mobs::Who,
|
||||
Result, actions::Action, block_entities::Pos, block_entity_render::vertex_light,
|
||||
containers::BlockEntity, items::ItemStack, mesher::Vertex, mobs::Who,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
@@ -25,6 +25,13 @@ impl Game<'_> {
|
||||
/// to the server. Whether the food went in; None when this isn't a
|
||||
/// campfire use, so the item's own use runs.
|
||||
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
|
||||
// `ServerPlayerGameMode.useItemOn`'s `suppressUsingBlock`: sneaking
|
||||
// with a stack in either hand never reaches the block's `useItemOn`,
|
||||
// so the food stays in the hand and the item's own use (eating)
|
||||
// runs instead.
|
||||
if self.keys.held(Action::Sneak) {
|
||||
return Ok(None);
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !is_campfire(&self.res.facts().get(state).name) {
|
||||
return Ok(None);
|
||||
@@ -60,6 +67,11 @@ impl Game<'_> {
|
||||
);
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
// `ServerPlayerGameMode.useItemOn` triggers ITEM_USED_ON_BLOCK with
|
||||
// the stack before the use once `useItemOn` consumed the click, both
|
||||
// when the food went in (SUCCESS_SERVER) and on a full campfire
|
||||
// (CONSUME).
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &held);
|
||||
Ok(Some(placed))
|
||||
}
|
||||
|
||||
@@ -198,3 +210,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "campfire_tests.rs"]
|
||||
mod campfire_tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Campfire food placement's gate and criterion: `CampfireBlock.useItemOn`
|
||||
//! reached through `ServerPlayerGameMode.useItemOn`, which skips the block
|
||||
//! while sneaking with something in hand and then triggers
|
||||
//! ITEM_USED_ON_BLOCK for the use it consumed.
|
||||
use super::*;
|
||||
use crate::game::{Hit, native_machines::tests::server_game};
|
||||
use horizon_client::{
|
||||
advancements::{Advancement, Criterion, Rewards, Tree},
|
||||
held::Source,
|
||||
items::ItemStack,
|
||||
motion::PlayerMotion,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::lazy_json::LazyJson;
|
||||
use std::sync::Arc;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// A server game with a campfire at `pos` (its block entity too) and beef in
|
||||
/// the main hand, the click forced onto that block.
|
||||
fn campfire_game(pos: [i32; 3]) -> Game<'static> {
|
||||
let mut game = server_game();
|
||||
// A local player, not a dedicated server's silent world: criteria and
|
||||
// statistics apply to them.
|
||||
game.options.dedicated = None;
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:campfire")
|
||||
.unwrap();
|
||||
game.world.set(pos[0], pos[1], pos[2], state).unwrap();
|
||||
game.world.block_entities.put(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Campfire(Box::default()),
|
||||
);
|
||||
let mut player = PlayerMotion::new(
|
||||
game.motion,
|
||||
Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 2.5,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// `Player.canEat`: hungry, so the beef's own use can start.
|
||||
player.health.food.food = 6;
|
||||
game.player = Some(player);
|
||||
game.creative = false;
|
||||
*game.held_mut() = Some(ItemStack::new("minecraft:beef", 4));
|
||||
game.forced_hit = Some(Hit {
|
||||
pos,
|
||||
face: [0, 1, 0],
|
||||
point: [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 1.0,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
});
|
||||
game
|
||||
}
|
||||
|
||||
/// The campfire's four cooking slots.
|
||||
fn slots(game: &Game<'_>, pos: [i32; 3]) -> usize {
|
||||
match game.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Campfire(c)) => c.items.iter().filter(|i| i.is_some()).count(),
|
||||
_ => panic!("no campfire block entity at {pos:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayerGameMode.useItemOn`: with something in hand, sneaking skips
|
||||
/// `state.useItemOn` entirely (`suppressUsingBlock`), so the beef is neither
|
||||
/// placed nor consumed; the item's own use, eating, starts instead. Without
|
||||
/// the sneak the campfire takes the beef, and the use it consumed triggers
|
||||
/// ITEM_USED_ON_BLOCK with the stack before the use.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_sneak_keeps_the_food_and_placing_it_fires_item_used_on_block() {
|
||||
const POS: [i32; 3] = [2, 4, 3];
|
||||
let mut game = campfire_game(POS);
|
||||
// The tree a data pack with one `item_used_on_block` criterion on this
|
||||
// campfire would be indexed into.
|
||||
let mut tree = Tree::default();
|
||||
tree.list.push(Advancement {
|
||||
id: "horizon:test/campfire".into(),
|
||||
parent: None,
|
||||
display: None,
|
||||
criteria: vec![Criterion {
|
||||
name: "food".into(),
|
||||
trigger: "minecraft:item_used_on_block".into(),
|
||||
conditions: LazyJson::from(serde_json::json!({
|
||||
"location": {
|
||||
"type": "minecraft:location_check",
|
||||
"predicate": {"block": {"blocks": "minecraft:campfire"}}
|
||||
}
|
||||
})),
|
||||
}],
|
||||
requirements: vec![vec!["food".into()]],
|
||||
rewards: Rewards::default(),
|
||||
});
|
||||
tree.by_id.insert("horizon:test/campfire".into(), 0);
|
||||
tree.by_trigger
|
||||
.insert("minecraft:item_used_on_block".into(), vec![(0, 0)]);
|
||||
game.adv.tree = Arc::new(tree);
|
||||
let hit = game.forced_hit.unwrap();
|
||||
game.keys.press(KeyCode::ShiftLeft, Source::Keyboard);
|
||||
assert!(game.use_hand(Some(&hit)).unwrap());
|
||||
assert_eq!(slots(&game, POS), 0, "the sneak put no beef on the fire");
|
||||
assert_eq!(game.held().unwrap().count, 4, "the beef stayed in hand");
|
||||
assert!(game.using.is_some(), "the beef is being eaten");
|
||||
assert!(
|
||||
!game.adv.progress.has("horizon:test/campfire", "food"),
|
||||
"the skipped block use triggers nothing"
|
||||
);
|
||||
// Stop eating, let go of the sneak: the same click now feeds the fire.
|
||||
game.using = None;
|
||||
game.keys.release(&KeyCode::ShiftLeft, Source::Keyboard);
|
||||
assert!(game.use_hand(Some(&hit)).unwrap());
|
||||
assert_eq!(slots(&game, POS), 1, "the beef went on the fire");
|
||||
assert_eq!(game.held().unwrap().count, 3, "one beef was used");
|
||||
assert!(game.using.is_none(), "the click was consumed");
|
||||
assert!(
|
||||
game.adv.progress.has("horizon:test/campfire", "food"),
|
||||
"the placed food fired item_used_on_block"
|
||||
);
|
||||
}
|
||||
@@ -608,7 +608,13 @@ impl Game<'_> {
|
||||
if !host && !guest {
|
||||
return;
|
||||
}
|
||||
let message = self.mob_death_message(m, killer);
|
||||
let mut built = Some(self.mob_death_message(m, killer));
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
horizon_client::living_death::native_living_death_message(m.id, &kind, &mut built);
|
||||
// A native mod may have changed the message, or set it to None to tell the owner nothing.
|
||||
let Some(message) = built else {
|
||||
return;
|
||||
};
|
||||
if host {
|
||||
self.system_message(message);
|
||||
} else if let Some(lan) = &self.lan {
|
||||
|
||||
@@ -380,7 +380,7 @@ impl Game<'_> {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::lan::mob_meta(
|
||||
m,
|
||||
hub,
|
||||
hub.sync_data(),
|
||||
None,
|
||||
g.world.game_time,
|
||||
&mut meta,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub(super) use data::place_summoned;
|
||||
mod effects;
|
||||
pub(super) mod execute;
|
||||
pub(super) mod extra;
|
||||
mod item_count;
|
||||
pub(in crate::game) mod items;
|
||||
mod misc;
|
||||
mod place;
|
||||
@@ -3951,6 +3952,12 @@ impl Game<'_> {
|
||||
};
|
||||
let present = components.get(&id.as_str().into());
|
||||
let result = match term[split..].chars().next() {
|
||||
// Both count wrappers in `ItemPredicateArgument` use the live
|
||||
// stack count and the same integer bounds matcher.
|
||||
Some('=' | '~') if id == "minecraft:count" => {
|
||||
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim());
|
||||
value.is_ok_and(|(v, _)| item_count::matches(stack.count, &v))
|
||||
}
|
||||
Some('=') => {
|
||||
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim())
|
||||
.ok()
|
||||
@@ -3964,28 +3971,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some('~') => {
|
||||
// Sub-predicates: `count` and component predicates.
|
||||
// Component sub-predicates.
|
||||
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim())
|
||||
.ok()
|
||||
.map(|(t, _)| t);
|
||||
match (id.as_str(), value) {
|
||||
("minecraft:count", Some(v)) => {
|
||||
let n = i64::from(stack.count);
|
||||
match &v {
|
||||
horizon_world::nbt::Tag::Compound(_) => {
|
||||
let get = |k: &str| match v.get(k) {
|
||||
Some(horizon_world::nbt::Tag::Int(i)) => {
|
||||
Some(i64::from(*i))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
get("min").is_none_or(|m| n >= m)
|
||||
&& get("max").is_none_or(|m| n <= m)
|
||||
}
|
||||
horizon_world::nbt::Tag::Int(i) => n == i64::from(*i),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
// `MinMaxBounds.Ints.matches`: DamagePredicate's damage-only
|
||||
// Int bounds shape; effective DAMAGE presence is required.
|
||||
// Other fields keep the entire existing predicate path.
|
||||
@@ -6044,3 +6034,7 @@ mod global_selector_order_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "item_count_predicate_tests.rs"]
|
||||
mod item_count_predicate_tests;
|
||||
@@ -516,9 +516,11 @@ impl Game<'_> {
|
||||
new.glow = old.glow;
|
||||
// ItemFrame.readAdditionalSaveData: ItemStack.matches includes
|
||||
// count, item and components, before adopting the new stack.
|
||||
// It compares the stack as read; setItem keeps one of it
|
||||
// only after, as `new.item` already does.
|
||||
let read = tag.get("Item").and_then(save::item_from_nbt);
|
||||
if old.item.as_ref().is_some_and(|a| {
|
||||
new.item
|
||||
.as_ref()
|
||||
read.as_ref()
|
||||
.is_none_or(|b| a.count != b.count || !a.same_item_same_components(b))
|
||||
}) {
|
||||
let old = old.clone();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The `count` wrapper of an item predicate (`ItemPredicateArgument`): both
|
||||
//! `count=` and `count~` read the live stack count.
|
||||
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `MinMaxBounds.Ints.matches` on the stack count: an exact Int, or a
|
||||
/// compound with optional Int `min` and `max`.
|
||||
pub(super) fn matches(count: impl Into<i64>, want: &Tag) -> bool {
|
||||
let n = count.into();
|
||||
match want {
|
||||
Tag::Compound(_) => {
|
||||
let get = |k: &str| match want.get(k) {
|
||||
Some(Tag::Int(i)) => Some(i64::from(*i)),
|
||||
_ => None,
|
||||
};
|
||||
get("min").is_none_or(|m| n >= m) && get("max").is_none_or(|m| n <= m)
|
||||
}
|
||||
Tag::Int(i) => n == i64::from(*i),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,15 @@ impl Game<'_> {
|
||||
let Some(stack) = stack.filter(|s| &*s.item != "minecraft:air" && s.count > 0) else {
|
||||
return true;
|
||||
};
|
||||
// SulfurCube.getEquipmentSlotForItem: a swallowable item is a body item.
|
||||
if let Slot::Mob(id, "body") = s
|
||||
&& self.mobs.iter().find(|m| m.id == *id).is_some_and(|m| {
|
||||
m.species() == horizon_client::mobs::Species::SulfurCube
|
||||
&& horizon_client::mobs::sulfur_cube::swallowable(m, &stack.item)
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
self.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|info| info.equip_slot.as_deref() == Some(name))
|
||||
@@ -1979,6 +1988,31 @@ mod mob_equipment_command_tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `SulfurCube.getEquipmentSlotForItem`: a `#sulfur_cube_swallowable`
|
||||
/// item is a body item, so the body's `SlotAccess` takes it (#12038).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_sulfur_cubes_body_takes_a_swallowable_block() {
|
||||
with_game(|game| {
|
||||
assert!(game.run_command("summon sulfur_cube 4.5 200 4.5 {NoAI:1b,Tags:[\"cube\"]}"));
|
||||
let body = |game: &Game<'_>| {
|
||||
let m = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.kind.id == "minecraft:sulfur_cube");
|
||||
m.and_then(|m| m.extra.body.clone())
|
||||
};
|
||||
let replace = "item replace entity @e[tag=cube,limit=1] armor.body with";
|
||||
assert!(game.run_command(&format!("{replace} white_wool")));
|
||||
assert_eq!(body(game).as_deref(), Some("minecraft:white_wool"));
|
||||
// An item outside the tag stays out, and the body stays on.
|
||||
assert!(!game.run_command(&format!("{replace} diamond")));
|
||||
assert_eq!(body(game).as_deref(), Some("minecraft:white_wool"));
|
||||
// An empty stack always fits: the body comes off.
|
||||
assert!(game.run_command(&format!("{replace} air")));
|
||||
assert_eq!(body(game), None);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn effective_item_composition_rejects_before_destination_mutation() {
|
||||
|
||||
@@ -770,7 +770,13 @@ impl Game<'_> {
|
||||
let mut pending = crate::game::spill::Pending::default();
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let has_block_entity = if let Some(entity) = self.world.block_entities.get(&key) {
|
||||
pending.record(p, entity, &name, self.world.block_names.get(&key).cloned());
|
||||
pending.record(
|
||||
p,
|
||||
entity,
|
||||
&name,
|
||||
self.world.block_names.get(&key).cloned(),
|
||||
self.world.block_lock(key),
|
||||
);
|
||||
true
|
||||
} else if let Some(saved) = self.block_entity_nbt(p) {
|
||||
pending.record_saved(p, &saved);
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod bred_draws;
|
||||
mod mob_geometry;
|
||||
mod mob_group;
|
||||
mod prep;
|
||||
mod projectile_world;
|
||||
mod pufferfish_player;
|
||||
mod spawn_eggs;
|
||||
@@ -1048,18 +1051,11 @@ impl Game<'_> {
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.items()),
|
||||
lid_openers: &self.lid_openers,
|
||||
mod_effects: if self.remote.is_none() {
|
||||
Some(self)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
mod_effects: self.remote.is_none().then_some(self as _),
|
||||
// A warden's away suspects are UUIDs: on the server side, where the
|
||||
// entity UUIDs live, they resolve back to the mobs that are here.
|
||||
who_lookup: if self.remote.is_none() {
|
||||
Some(self)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
who_lookup: self.remote.is_none().then_some(self as _),
|
||||
foreign: None,
|
||||
border: Some(self.border.geometry()),
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
@@ -1187,50 +1183,32 @@ impl Game<'_> {
|
||||
self.init_sulfur_attribute_maps();
|
||||
let uuid = self.level.player_uuid;
|
||||
let level_id = self.dim.id();
|
||||
// LivingEntity.isPushable reads onClimbable live; a mob's cached one
|
||||
// is only refreshed by its own push step, so one that has not ticked
|
||||
// yet (the first tick after a load, a fresh spawn) or has moved since
|
||||
// would be pushed out of a ladder or scaffolding. Refreshed here, from
|
||||
// where each mob stands now, before the snapshot others push it by.
|
||||
let climbing: Vec<bool> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
self.mobs.iter().map(|m| m.on_climbable(&w)).collect()
|
||||
};
|
||||
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
|
||||
m.on_climbable = climbing;
|
||||
}
|
||||
// Each mob's climbing, refreshed, and its snapshot entry (creatures/prep.rs).
|
||||
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
|
||||
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
|
||||
self.prepare_mobs(uuid, &mut snapshot);
|
||||
let mut events = std::mem::take(&mut self.creatures.event_buf);
|
||||
events.clear();
|
||||
// SulfurCube.primeWhenOnPoweredPosition runs before super.tick adopts
|
||||
// the body: the cubes whose adopted body explodes, as this tick starts.
|
||||
let primable = self.sulfur_cubes_adopted_explosive();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
let mobs = std::mem::take(&mut self.mobs);
|
||||
// Each mob's target as the mods last heard it, by its place in `mobs`
|
||||
// (`native_living_change_target`).
|
||||
let mut targets = std::mem::take(&mut self.creatures.target_buf);
|
||||
targets.clear();
|
||||
targets.extend(mobs.iter().map(|m| m.ai.target));
|
||||
let mut dead = Vec::new();
|
||||
let mut corpses = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
let died = std::mem::take(&mut self.died);
|
||||
let mut ticked = std::mem::take(&mut self.creatures.ticked);
|
||||
ticked.clear();
|
||||
let ticking = self.entity_ticking_area();
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
let mut guests = self.lan_guest_views();
|
||||
// What stands in a guest's slot while a mob holds that guest's view for
|
||||
// its tick (see `lent` below).
|
||||
let mut spare = guests.first().map(|g| g.1.clone());
|
||||
let players = self.level_players();
|
||||
let nearest = super::natural_spawning::NearestPlayers::new(&players);
|
||||
let mut bindings = match self.lan.as_mut() {
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
let guests = self.lan_guest_views();
|
||||
let bindings = match self.lan.as_mut() {
|
||||
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The guests' entity ids by connection, for the shulker bullets fired
|
||||
// at the guest a mob sees.
|
||||
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
|
||||
Some(host) if !guests.is_empty() => host
|
||||
.hub
|
||||
@@ -1240,23 +1218,9 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
// The block writes since the last tick: mobs re-path around them.
|
||||
let path_writes = std::mem::take(self.world.path_writes.get_or_insert_with(Vec::new));
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height, split).
|
||||
// 🤖 Agent: the last field is the grown slime, magma cube or sulfur
|
||||
// cube the body leaves behind (`AbstractCubeMob.remove`), captured as
|
||||
// the body goes because it has left the list by the time it splits.
|
||||
let mut gone = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
// The level random, lent to the mobs (brain behaviours draw from
|
||||
// it) and taken back after their ticks.
|
||||
let mut scratch = self.world.random.clone();
|
||||
let mut pushes = horizon_client::mobs::PendingPushes::default();
|
||||
let mut keep = Vec::with_capacity(mobs.len());
|
||||
let mut grid =
|
||||
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
|
||||
let group = {
|
||||
// All but the snapshot is the same for every mob of the tick, so
|
||||
// build it once: the player view and the teams scan every UUID.
|
||||
self.leash_boats = self
|
||||
@@ -1266,87 +1230,52 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let mut base = self.mob_world_base(&[]);
|
||||
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
let mut index = horizon_client::fast_hash::FxHashMap::with_capacity_and_hasher(
|
||||
snapshot.len(),
|
||||
Default::default(),
|
||||
);
|
||||
let mut villager_slots = Vec::new();
|
||||
let mut solid_slots = Vec::new();
|
||||
for (i, s) in snapshot.iter().enumerate() {
|
||||
index.entry(s.id).or_insert(i as u32);
|
||||
if s.species == horizon_client::mobs::Species::Villager {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
if s.may_block_mobs() {
|
||||
solid_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
laps.lap("creature_prepare");
|
||||
let single_player = horizon_client::mobs::single_player_pos(
|
||||
players.iter().map(|p| (p.position, p.spectator)),
|
||||
);
|
||||
let nearest = prep::MobsNearest::new(&nearest, &mobs, single_player.is_none());
|
||||
let mut despawn_w = base.despawn_view();
|
||||
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
|
||||
for i in 0..mobs.len() {
|
||||
let m = &mut mobs[i];
|
||||
let (bx, bz) = (m.position.x.floor() as i32, m.position.z.floor() as i32);
|
||||
let seen_guest = if guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let bound = bindings.get(&m.id).copied();
|
||||
let conn = super::lan_mobs::player_for(m, base.player.as_ref(), &guests, bound);
|
||||
match conn {
|
||||
Some(conn) => {
|
||||
bindings.insert(m.id, conn);
|
||||
}
|
||||
None => {
|
||||
bindings.remove(&m.id);
|
||||
}
|
||||
}
|
||||
conn
|
||||
};
|
||||
let shared = mob_group::Shared {
|
||||
uuid,
|
||||
level_id,
|
||||
path_changes: &path_changes,
|
||||
guest_ids: &guest_ids,
|
||||
};
|
||||
let start = mob_group::Start {
|
||||
events,
|
||||
// The level random, lent to the mobs and taken back after their ticks.
|
||||
random: self.world.random.clone(),
|
||||
guests,
|
||||
player: base.player.take(),
|
||||
bindings,
|
||||
died,
|
||||
ticked,
|
||||
targets,
|
||||
};
|
||||
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
|
||||
for i in 0..group.mobs.len() {
|
||||
let position = group.mobs[i].position;
|
||||
let (bx, bz) = (position.x.floor() as i32, position.z.floor() as i32);
|
||||
let seen_guest = group.bind_guest(i);
|
||||
if !base.world.loaded(bx, bz) {
|
||||
keep.push(!m.hostile() || m.persistent);
|
||||
group.unloaded(i);
|
||||
continue;
|
||||
}
|
||||
let nearest_player = single_player.unwrap_or_else(|| {
|
||||
nearest
|
||||
.nearest(m.position.x, m.position.y, m.position.z)
|
||||
.map(|p| p.position)
|
||||
});
|
||||
let nearest_player = single_player.unwrap_or_else(|| nearest.at(i, position));
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick. The despawn check reads the nearest player
|
||||
// and the difficulty, not the player the mob sees.
|
||||
// distance) tick.
|
||||
if !ticking(bx, bz) {
|
||||
despawn_w.nearest_player = nearest_player;
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &despawn_w);
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
}
|
||||
keep.push(kept);
|
||||
group.rest(i, &despawn_w);
|
||||
continue;
|
||||
}
|
||||
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
base.player
|
||||
.as_ref()
|
||||
.filter(|p| p.riding_vehicle == Some(m.id))
|
||||
.map(|p| p.velocity)
|
||||
.or_else(|| {
|
||||
guests
|
||||
.iter()
|
||||
.find(|(_, p)| p.riding_vehicle == Some(m.id))
|
||||
.map(|(_, p)| p.velocity)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let controlling = group.controlling(i);
|
||||
// A mod canceled its tick: it stays as it is.
|
||||
if round.canceled(m.id) {
|
||||
keep.push(true);
|
||||
if round.canceled(group.mobs[i].id) {
|
||||
group.keep.push(true);
|
||||
continue;
|
||||
}
|
||||
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
|
||||
@@ -1356,175 +1285,34 @@ impl Game<'_> {
|
||||
Some((key, difficulty)) if key == chunk_key => difficulty,
|
||||
_ => {
|
||||
let difficulty =
|
||||
self.local_difficulty([m.position.x, m.position.y, m.position.z]);
|
||||
self.local_difficulty([position.x, position.y, position.z]);
|
||||
cached_difficulty = Some((chunk_key, difficulty));
|
||||
difficulty
|
||||
}
|
||||
};
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
// The guest's view is lent to the mob for its tick, not copied:
|
||||
// the tick only reads it (`&MobWorld`), and it goes back to the
|
||||
// guest's slot straight after.
|
||||
let lent = seen_guest.and_then(|conn| guests.iter().position(|g| g.0 == conn));
|
||||
let active_player = match (seen_guest, lent) {
|
||||
(None, _) => base.player.take(),
|
||||
(Some(_), Some(k)) => spare
|
||||
.take()
|
||||
.map(|stand_in| std::mem::replace(&mut guests[k].1, stand_in)),
|
||||
(Some(_), None) => None,
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
others_solid: Some(&solid_slots),
|
||||
difficulty,
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
ticked.insert(mobs[i].id);
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled = super::tick_profile::start().map(|t| (t, mobs[i].kind.clone()));
|
||||
let kept =
|
||||
horizon_client::mobs::riders::tick(&mut mobs, i, &w, &mut scratch, &mut events);
|
||||
if let Some((started, kind)) = sampled {
|
||||
super::tick_profile::add(level_id, &kind, started);
|
||||
}
|
||||
let m = &mut mobs[i];
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
} else if let Some(k) = lent {
|
||||
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
|
||||
}
|
||||
// Its tick changed its target: the mods may keep the one it had
|
||||
// (`native_living_change_target`), and then what the tick did to
|
||||
// the new one is not dealt.
|
||||
if let Some(&before) = targets.get(i)
|
||||
&& m.ai.target != before
|
||||
{
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Self::native_living_change_target(
|
||||
level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
if let Some(new) = m.ai.target {
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if hits_target(&events[k], new) {
|
||||
events.remove(k);
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
targets[i] = m.ai.target;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
corpses.push(m.clone());
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
// 🤖 Agent: `AbstractCubeMob.remove` splits a grown cube into
|
||||
// its smaller halves when it is removed, twenty ticks after
|
||||
// the death. The parent leaves the list before the split
|
||||
// runs, so its size, position, kind and persistence are
|
||||
// captured here, as they are at the removal.
|
||||
let split = if matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::SulfurCube
|
||||
) && m.extra.size > 1
|
||||
{
|
||||
Some(m.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
gone.push((m.id, m.position, width, height, split));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Some(Who::Player)
|
||||
{
|
||||
*target = Some(Who::Mob(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if super::lan_mobs::acts_on_player(&events[k]) {
|
||||
guest_events.push((conn, m.id, events.remove(k)));
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Killed during its own tick (drowning, burning, falling...).
|
||||
if !m.alive() && !m.removed && died.insert(m.id) {
|
||||
dead.push(m.clone());
|
||||
}
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
}
|
||||
keep.push(kept);
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
snapshot[i] = seen(m, uuid);
|
||||
snapshot[i].alive &= kept;
|
||||
grid.update(i, snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
let player = (seen_guest, nearest_player, controlling);
|
||||
group.tick(i, player, difficulty, &base, &shared);
|
||||
}
|
||||
// Targets changed after the mob's own tick (another mob's alert):
|
||||
// heard now, and a cancel puts the old one back before its next tick.
|
||||
for (i, m) in mobs.iter_mut().enumerate() {
|
||||
let Some(&before) = targets.get(i) else {
|
||||
for (i, m) in group.mobs.iter_mut().enumerate() {
|
||||
let Some(&before) = group.targets.get(i) else {
|
||||
break;
|
||||
};
|
||||
if m.ai.target == before {
|
||||
continue;
|
||||
}
|
||||
let seen_guest = if guests.is_empty() {
|
||||
let seen_guest = if group.guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
bindings.get(&m.id).copied()
|
||||
group.bindings.get(&m.id).copied()
|
||||
};
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&group.guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
group.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
@@ -1543,13 +1331,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
laps.lap("creature_loop");
|
||||
let mut flags = keep.into_iter();
|
||||
mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
for m in &mut mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let mut flags = std::mem::take(&mut group.keep).into_iter();
|
||||
group.mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
for m in &mut group.mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut group.pushes);
|
||||
}
|
||||
level_random = scratch;
|
||||
}
|
||||
group
|
||||
};
|
||||
let mob_group::Group {
|
||||
mut mobs,
|
||||
mut snapshot,
|
||||
mut events,
|
||||
random: level_random,
|
||||
guests,
|
||||
mut bindings,
|
||||
died,
|
||||
ticked,
|
||||
mut targets,
|
||||
dead,
|
||||
corpses,
|
||||
gone,
|
||||
guest_events,
|
||||
..
|
||||
} = group;
|
||||
self.world.random = level_random;
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& !guests.is_empty()
|
||||
@@ -1565,7 +1369,7 @@ impl Game<'_> {
|
||||
// first, as `append` put them; with none, the ticked list goes back
|
||||
// as it is instead of being copied into a new one, every mob a tick.
|
||||
if self.mobs.is_empty() {
|
||||
self.mobs = mobs;
|
||||
self.mobs = mobs.into();
|
||||
} else {
|
||||
self.mobs.append(&mut mobs);
|
||||
}
|
||||
@@ -3869,18 +3673,18 @@ impl Game<'_> {
|
||||
q.extra.in_love = 0;
|
||||
}
|
||||
let q = self.mobs.iter().find(|m| m.id == partner).cloned();
|
||||
// Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent.
|
||||
self.mob_event_particles(parent, 18);
|
||||
// Animal.finalizeSpawnChildFromBreeding's heart burst, not a turtle's.
|
||||
if p.species() != Species::Turtle {
|
||||
self.mob_event_particles(parent, 18);
|
||||
}
|
||||
// Sniffer.spawnChildFromBreeding: an egg instead of a baby.
|
||||
if p.species() == Species::Sniffer {
|
||||
self.lay_sniffer_egg(&p);
|
||||
return;
|
||||
}
|
||||
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
|
||||
// experience is left, and only with mob loot on.
|
||||
// TurtleBreedGoal.breed and Frog.spawnChildFromBreeding: only experience.
|
||||
if matches!(p.species(), Species::Turtle | Species::Frog) {
|
||||
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
|
||||
// split up as `ExperienceOrb.award` does).
|
||||
// One `new ExperienceOrb` of 1 to 7 from the mother's random.
|
||||
if self.ui.rules.bool("mob_drops")
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
|
||||
{
|
||||
@@ -3889,9 +3693,7 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// (Mooshroom draws its own coin in getOffspringVariant.)
|
||||
let coin = p.species() != Species::Mooshroom
|
||||
&& self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
let coin = self.bred_coat_coin(parent, p.species());
|
||||
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
|
||||
// else either parent's variant. The draws come from the mother's random.
|
||||
let axolotl = if p.species() == Species::Axolotl {
|
||||
@@ -3943,7 +3745,7 @@ impl Game<'_> {
|
||||
// No Mob.finalizeSpawn either: no follow range bonus, right-handed.
|
||||
spawning::bred_offspring(child);
|
||||
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
|
||||
// the default (`classic`) sound variant.
|
||||
// the default (`classic`) sound variant (a wolf picks its own below).
|
||||
child.extra.sound_variant = None;
|
||||
// Fox.getBreedOffspring runs no finalizeSpawn: nothing in the
|
||||
// baby fox's mouth.
|
||||
@@ -4058,6 +3860,7 @@ impl Game<'_> {
|
||||
c.extra.color = mixed;
|
||||
}
|
||||
}
|
||||
self.pick_bred_wolf_voice(parent, id);
|
||||
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
|
||||
return;
|
||||
}
|
||||
@@ -4546,6 +4349,7 @@ impl Game<'_> {
|
||||
// The impacts the mods may cancel (`native_projectile_impact`), on a server.
|
||||
let hear = self.native_hears_impacts();
|
||||
let mut impacts: Vec<Impact> = Vec::new();
|
||||
let mut vehicle_winds = self.vehicle_winds();
|
||||
{
|
||||
let targets = self.projectile_targets();
|
||||
let breezes: Vec<u32> = self
|
||||
@@ -4653,6 +4457,7 @@ impl Game<'_> {
|
||||
};
|
||||
// The projectile as it was, to fly on if a mod cancels its impact.
|
||||
let before = (hear && !p.stuck).then(|| p.clone());
|
||||
let wind_before = vehicle_winds.before(p);
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
// A player's arrows, snowballs and eggs meet every player; a
|
||||
// guest's flies from that guest (Projectile.getOwner), not
|
||||
@@ -4787,6 +4592,7 @@ impl Game<'_> {
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
vehicle_winds.met(wind_before, hit);
|
||||
if hit != Hit::None {
|
||||
if let Some(before) = before {
|
||||
impacts.push(Impact {
|
||||
@@ -4859,6 +4665,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.creatures.projectiles.append(&mut projectiles);
|
||||
for (p, hit) in hits {
|
||||
let Some((p, hit)) = self.broken_vehicle_retick(p, hit, &mut vehicle_winds) else {
|
||||
continue;
|
||||
};
|
||||
// A player's arrow on another player lands as the victim's hurt.
|
||||
if let Some(victim) = self.projectile_pvp_victim(&p, hit) {
|
||||
self.projectile_hits_player(&p, hit, victim)?;
|
||||
@@ -4973,17 +4782,6 @@ impl Game<'_> {
|
||||
8.0,
|
||||
)
|
||||
}
|
||||
/// A projectile's hurt of `amount` on the boat or minecart `id`
|
||||
/// (`VehicleEntity.hurtServer`): even 0 shakes it (`Snowball`, `ThrownEgg`,
|
||||
/// `ThrownEnderpearl` `.onHitEntity`), 5 from a small fireball destroys it.
|
||||
fn hurt_vehicle(&mut self, p: &Projectile, id: u32, amount: f32) {
|
||||
let creative = p.owner == Some(Who::Player) && self.creative;
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.hurt_boat(i, amount, creative);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.hurt_cart(i, amount, creative, false);
|
||||
}
|
||||
}
|
||||
/// `BellBlock.onProjectileHit`: a projectile that strikes the bell proper
|
||||
/// rings it, and a player's shot counts as the player's ring.
|
||||
fn bell_projectile_hit(
|
||||
@@ -5168,19 +4966,16 @@ impl Game<'_> {
|
||||
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
|
||||
}
|
||||
}
|
||||
/// `Level.isRainingAt` for arrows.
|
||||
/// The boats, minecarts and primed TNT a projectile can strike
|
||||
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`).
|
||||
pub(super) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let boats = self.boats.iter().map(|b| (Who::Mob(b.id), b.bounds()));
|
||||
let carts = self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()));
|
||||
let tnts = self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds()));
|
||||
boats.chain(carts).chain(tnts).collect()
|
||||
}
|
||||
/// What a projectile can hit: the mobs, the player, the dragon's parts and
|
||||
/// the End crystals (under reserved ids, game/dragon.rs).
|
||||
/// The boats and minecarts a projectile can strike.
|
||||
pub(super) fn vehicle_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| (Who::Mob(b.id), b.bounds()))
|
||||
.collect();
|
||||
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
|
||||
targets
|
||||
}
|
||||
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
@@ -5198,9 +4993,9 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
// Boats and minecarts are pickable for every projectile
|
||||
// (`VehicleEntity.isPickable`), so every kind shares this one list.
|
||||
targets.extend(self.vehicle_targets());
|
||||
// Boats, minecarts and primed TNT are pickable for every projectile,
|
||||
// so every kind shares this one list.
|
||||
targets.extend(self.pickable_object_targets());
|
||||
targets
|
||||
}
|
||||
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
|
||||
@@ -8376,6 +8171,7 @@ mod pearl_tests {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! The breeding parent's own random in `getBreedOffspring`: the offspring's
|
||||
//! coat coin and a wolf pup's voice.
|
||||
use super::super::Game;
|
||||
use horizon_client::mobs::Species;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Cow, Pig, Chicken, Fox, Cat and Wolf.getBreedOffspring: the offspring's
|
||||
/// variant coin is the breeding parent's own first draw, not the level's.
|
||||
/// (Mooshroom draws its own coin in getOffspringVariant.)
|
||||
pub(super) fn bred_coat_coin(&mut self, parent: u32, species: Species) -> bool {
|
||||
matches!(
|
||||
species,
|
||||
Species::Cow
|
||||
| Species::Pig
|
||||
| Species::Chicken
|
||||
| Species::Fox
|
||||
| Species::Cat
|
||||
| Species::Wolf
|
||||
) && self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == parent)
|
||||
.is_some_and(|m| m.random.next_boolean())
|
||||
}
|
||||
/// WolfSoundVariants.pickRandomSoundVariant: Registry.getRandom over the
|
||||
/// wolf voice registry, from the parent's own random, after the collar
|
||||
/// colour's mix.
|
||||
pub(super) fn pick_bred_wolf_voice(&mut self, parent: u32, child: u32) {
|
||||
let voices = horizon_client::mobs::finalize::sound_variants(Species::Wolf);
|
||||
let voice = self.mobs.iter_mut().find(|m| m.id == parent).and_then(|m| {
|
||||
let i = m.random.next_int_bound(voices.len() as i32).ok()?;
|
||||
Some(i as u8)
|
||||
});
|
||||
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == child) {
|
||||
c.extra.sound_variant = voice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
|
||||
/// `Cow`, `Pig`, `Chicken`, `Fox`, `Cat` and `Wolf.getBreedOffspring` draw
|
||||
/// the offspring's coat from the breeding parent's own random, and only a
|
||||
/// wolf picks a voice there (`WolfSoundVariants.pickRandomSoundVariant`,
|
||||
/// that same random, after the collar colour's mix). The level's random
|
||||
/// answers neither: two runs that differ only in the level's seed must
|
||||
/// agree, and the parent's stream must read as vanilla's own draws in
|
||||
/// vanilla's order.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_pup_takes_its_coat_and_voice_from_the_parents_own_random() {
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
// The voice registry, not a hardcoded count of wolf voices.
|
||||
let voices = horizon_client::mobs::finalize::sound_variants(Species::Wolf).len() as i32;
|
||||
assert!(voices > 1, "a wolf has several voices");
|
||||
let cases = [
|
||||
("minecraft:wolf", 2u8, 8u8),
|
||||
("minecraft:wolf", 6, 0),
|
||||
("minecraft:cow", 1, 0),
|
||||
("minecraft:pig", 2, 0),
|
||||
("minecraft:chicken", 0, 1),
|
||||
("minecraft:fox", 1, 0),
|
||||
("minecraft:cat", 4, 0),
|
||||
];
|
||||
let mut heard = std::collections::BTreeSet::new();
|
||||
// One server game: its resources load once, and only the level's seed
|
||||
// and the pair change between runs.
|
||||
let mut game = super::super::super::native_machines::tests::server_game();
|
||||
for (n, &(kind, own, mate)) in cases.iter().enumerate() {
|
||||
// Independent entity randoms, kept clear of the ids the spawner
|
||||
// hands out: the parent is `part`, the partner `mate_id`.
|
||||
let parent_seed = 4_242 + 1_009 * n as i64;
|
||||
let mut outcomes = Vec::new();
|
||||
for (r, level_seed) in [11i64, 2_000_003].into_iter().enumerate() {
|
||||
game.world.random = LegacyRandom::new(level_seed);
|
||||
game.mobs.clear();
|
||||
let (part, mate_id) = (7u32, 8u32);
|
||||
// Ids the spawner hands out stay clear of the pair's.
|
||||
game.next_entity = 1_000 + 10 * n as u32 + 2 * r as u32;
|
||||
let t = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get(kind)
|
||||
.unwrap_or_else(|| panic!("a {kind}"))
|
||||
.clone();
|
||||
for (id, variant) in [(part, own), (mate_id, mate)] {
|
||||
let mut m = Mob::new(id, t.clone(), Vec3::new(8.5, 64.0, 8.5), 0.0);
|
||||
m.random = LegacyRandom::new(parent_seed + i64::from(id));
|
||||
m.extra.variant = variant;
|
||||
m.extra.in_love = 600;
|
||||
game.mobs.push(m);
|
||||
}
|
||||
game.breed(part, mate_id, None);
|
||||
let child = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id != part && m.id != mate_id)
|
||||
.unwrap_or_else(|| panic!("{kind}: a pup"));
|
||||
assert_eq!(child.extra.age, -24000, "{kind}: a baby");
|
||||
// What the parent's own stream answers, draw for draw.
|
||||
let mut expected = LegacyRandom::new(parent_seed + i64::from(part));
|
||||
let coin = expected.next_boolean();
|
||||
let coat = if coin { own } else { mate };
|
||||
let voice = (kind == "minecraft:wolf").then(|| {
|
||||
let i = expected.next_int_bound(voices).unwrap() as u8;
|
||||
heard.insert(i);
|
||||
i
|
||||
});
|
||||
assert_eq!(
|
||||
child.extra.variant, coat,
|
||||
"{kind}: the parent's coin picks the coat (level seed {level_seed})"
|
||||
);
|
||||
assert_eq!(
|
||||
child.extra.sound_variant, voice,
|
||||
"{kind}: the parent's next draw picks the voice (level seed {level_seed})"
|
||||
);
|
||||
// The stream ends where vanilla's does: the coin, a wolf's
|
||||
// voice, then finalizeSpawnChildFromBreeding's 1-to-7 roll.
|
||||
if game.ui.rules.bool("mob_drops") {
|
||||
let _ = expected.next_int_bound(7);
|
||||
}
|
||||
let mut parent = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == part)
|
||||
.unwrap()
|
||||
.random
|
||||
.clone();
|
||||
for _ in 0..4 {
|
||||
assert_eq!(
|
||||
parent.next_int(),
|
||||
expected.next_int(),
|
||||
"{kind}: the parent's stream goes on where vanilla leaves it"
|
||||
);
|
||||
}
|
||||
outcomes.push((child.extra.variant, child.extra.sound_variant));
|
||||
}
|
||||
assert_eq!(
|
||||
outcomes[0], outcomes[1],
|
||||
"{kind}: the level's seed does not decide the pup"
|
||||
);
|
||||
}
|
||||
assert!(heard.len() > 1, "the wolf's voices vary: {heard:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! The mob loop of `tick_creature_entities` over a group of mobs, in their list order, with
|
||||
//! everything the loop keeps for them: their snapshot and its indexes, the pushes and events
|
||||
//! their ticks give, the level random they draw from, the players they see and what dies. The
|
||||
//! serial loop is one group holding every mob; a group of the parallel mob tick (#14457) holds
|
||||
//! the mobs no mob outside it can reach this tick.
|
||||
use super::*;
|
||||
use horizon_client::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
mobs::{MobSlot, PendingPushes, seen_grid::SeenGrid, spawning::LocalDifficulty},
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
/// What every mob of the loop reads and none writes.
|
||||
pub(super) struct Shared<'a> {
|
||||
pub uuid: Option<[i32; 4]>,
|
||||
pub level_id: &'static str,
|
||||
/// The blocks whose collision changed since the last tick: mobs re-path around them.
|
||||
pub path_changes: &'a [[i32; 3]],
|
||||
/// The guests' entity ids by connection, for the shulker bullets fired at the guest a mob
|
||||
/// sees.
|
||||
pub guest_ids: &'a [(u32, u32)],
|
||||
}
|
||||
|
||||
/// A mob whose body goes this tick: (id, feet, width, height, the grown cube it splits into).
|
||||
/// The last field is the grown slime, magma cube or sulfur cube the body leaves behind
|
||||
/// (`AbstractCubeMob.remove`), captured as the body goes because it has left the list by the
|
||||
/// time it splits.
|
||||
pub(super) type Gone = (u32, Vec3, f64, f64, Option<Mob>);
|
||||
|
||||
/// A group's mobs and everything the loop keeps for them, in their list order.
|
||||
pub(super) struct Group<M: MobSlot> {
|
||||
pub mobs: Vec<M>,
|
||||
/// Each mob's snapshot entry: as it was before the tick until its turn, as its turn left it
|
||||
/// after.
|
||||
pub snapshot: Vec<Seen>,
|
||||
grid: SeenGrid,
|
||||
/// The first slot of each id, as a scan would find it.
|
||||
index: FxHashMap<u32, u32>,
|
||||
villager_slots: Vec<u32>,
|
||||
solid_slots: Vec<u32>,
|
||||
pub pushes: PendingPushes,
|
||||
pub events: Vec<MobEvent>,
|
||||
/// The level random, lent to the mobs (brain behaviours draw from it).
|
||||
pub random: LegacyRandom,
|
||||
pub guests: super::super::lan_mobs::GuestViews,
|
||||
/// What stands in a guest's slot while a mob holds that guest's view for its tick.
|
||||
spare: Option<PlayerView>,
|
||||
/// The host's own player, lent to each mob that sees it for its tick.
|
||||
pub player: Option<PlayerView>,
|
||||
/// The guest each mob sees, by mob id.
|
||||
pub bindings: FxHashMap<u32, u32>,
|
||||
pub died: FxHashSet<u32>,
|
||||
pub ticked: FxHashSet<u32>,
|
||||
/// Each mob's target as the mods last heard it (`native_living_change_target`).
|
||||
pub targets: Vec<Option<Who>>,
|
||||
/// Whether each mob stays in the list, pushed at its turn.
|
||||
pub keep: Vec<bool>,
|
||||
pub dead: Vec<Mob>,
|
||||
pub corpses: Vec<Mob>,
|
||||
pub gone: Vec<Gone>,
|
||||
pub guest_events: Vec<(u32, u32, MobEvent)>,
|
||||
}
|
||||
|
||||
/// What a group starts from besides its mobs and their snapshot.
|
||||
pub(super) struct Start {
|
||||
pub events: Vec<MobEvent>,
|
||||
pub random: LegacyRandom,
|
||||
pub guests: super::super::lan_mobs::GuestViews,
|
||||
pub player: Option<PlayerView>,
|
||||
pub bindings: FxHashMap<u32, u32>,
|
||||
pub died: FxHashSet<u32>,
|
||||
pub ticked: FxHashSet<u32>,
|
||||
pub targets: Vec<Option<Who>>,
|
||||
}
|
||||
|
||||
impl<M: MobSlot> Group<M> {
|
||||
/// A group of `mobs`, whose snapshot entries are `snapshot`.
|
||||
pub(super) fn new(mobs: Vec<M>, snapshot: Vec<Seen>, start: Start) -> Self {
|
||||
let grid = SeenGrid::new(snapshot.iter().map(|s| s.bounds));
|
||||
let mut index = FxHashMap::with_capacity_and_hasher(snapshot.len(), Default::default());
|
||||
let (mut villager_slots, mut solid_slots) = (Vec::new(), Vec::new());
|
||||
for (i, s) in snapshot.iter().enumerate() {
|
||||
index.entry(s.id).or_insert(i as u32);
|
||||
if s.species == Species::Villager {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
if s.may_block_mobs() {
|
||||
solid_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
let n = mobs.len();
|
||||
Self {
|
||||
spare: start.guests.first().map(|g| g.1.clone()),
|
||||
mobs,
|
||||
snapshot,
|
||||
grid,
|
||||
index,
|
||||
villager_slots,
|
||||
solid_slots,
|
||||
pushes: PendingPushes::default(),
|
||||
events: start.events,
|
||||
random: start.random,
|
||||
guests: start.guests,
|
||||
player: start.player,
|
||||
bindings: start.bindings,
|
||||
died: start.died,
|
||||
ticked: start.ticked,
|
||||
targets: start.targets,
|
||||
keep: Vec::with_capacity(n),
|
||||
dead: Vec::new(),
|
||||
corpses: Vec::new(),
|
||||
gone: Vec::new(),
|
||||
guest_events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The guest mob `i` sees now, kept in the bindings: none while no guest is on.
|
||||
pub(super) fn bind_guest(&mut self, i: usize) -> Option<u32> {
|
||||
if self.guests.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let m = self.mobs[i].mob();
|
||||
let bound = self.bindings.get(&m.id).copied();
|
||||
let conn = super::super::lan_mobs::player_for(m, self.player.as_ref(), &self.guests, bound);
|
||||
match conn {
|
||||
Some(conn) => self.bindings.insert(m.id, conn),
|
||||
None => self.bindings.remove(&m.id),
|
||||
};
|
||||
conn
|
||||
}
|
||||
|
||||
/// Mob `i`'s turn when its chunk isn't loaded: it is kept as it is, unless it would despawn.
|
||||
pub(super) fn unloaded(&mut self, i: usize) {
|
||||
let m = self.mobs[i].mob();
|
||||
self.keep.push(!m.hostile() || m.persistent);
|
||||
}
|
||||
|
||||
/// Mob `i`'s turn outside the entity-ticking area: only its despawn check, which reads the
|
||||
/// nearest player and the difficulty, not the player the mob sees.
|
||||
pub(super) fn rest(&mut self, i: usize, despawn_w: &MobWorld) {
|
||||
let m = self.mobs[i].mob_mut();
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, despawn_w);
|
||||
if !kept {
|
||||
self.died.remove(&m.id);
|
||||
self.snapshot[i].alive = false;
|
||||
}
|
||||
self.keep.push(kept);
|
||||
}
|
||||
|
||||
/// Who controls mob `i`'s moves this tick: the velocity of the player riding it, when it is
|
||||
/// a controlled mount.
|
||||
pub(super) fn controlling(&self, i: usize) -> Option<Vec3> {
|
||||
let m = self.mobs[i].mob();
|
||||
horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
self.player
|
||||
.as_ref()
|
||||
.filter(|p| p.riding_vehicle == Some(m.id))
|
||||
.map(|p| p.velocity)
|
||||
.or_else(|| {
|
||||
self.guests
|
||||
.iter()
|
||||
.find(|(_, p)| p.riding_vehicle == Some(m.id))
|
||||
.map(|(_, p)| p.velocity)
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Mob `i`'s own tick, then what the loop does with what it did. `players`: the guest it
|
||||
/// sees (none: the host's player), its nearest player, and the velocity of the player
|
||||
/// controlling it as a mount ([`Self::controlling`]).
|
||||
pub(super) fn tick(
|
||||
&mut self,
|
||||
i: usize,
|
||||
(seen_guest, nearest_player, controlling_player_movement): (
|
||||
Option<u32>,
|
||||
Option<Vec3>,
|
||||
Option<Vec3>,
|
||||
),
|
||||
difficulty: LocalDifficulty,
|
||||
base: &MobWorld,
|
||||
shared: &Shared,
|
||||
) {
|
||||
let m = self.mobs[i].mob_mut();
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut self.pushes);
|
||||
horizon_client::mobs::blocks_changed(m, shared.path_changes);
|
||||
let from = self.events.len();
|
||||
// The guest's view is lent to the mob for its tick, not copied:
|
||||
// the tick only reads it (`&MobWorld`), and it goes back to the
|
||||
// guest's slot straight after.
|
||||
let lent = seen_guest.and_then(|conn| self.guests.iter().position(|g| g.0 == conn));
|
||||
let active_player = match (seen_guest, lent) {
|
||||
(None, _) => self.player.take(),
|
||||
(Some(_), Some(k)) => self
|
||||
.spare
|
||||
.take()
|
||||
.map(|stand_in| std::mem::replace(&mut self.guests[k].1, stand_in)),
|
||||
(Some(_), None) => None,
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &self.snapshot,
|
||||
others_grid: Some(&self.grid),
|
||||
others_index: Some(&self.index),
|
||||
others_villagers: Some(&self.villager_slots),
|
||||
others_solid: Some(&self.solid_slots),
|
||||
difficulty,
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..*base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
self.ticked.insert(self.mobs[i].mob().id);
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled =
|
||||
super::super::tick_profile::start().map(|t| (t, self.mobs[i].mob().kind.clone()));
|
||||
let kept = horizon_client::mobs::riders::tick(
|
||||
&mut self.mobs,
|
||||
i,
|
||||
&w,
|
||||
&mut self.random,
|
||||
&mut self.events,
|
||||
);
|
||||
if let Some((started, kind)) = sampled {
|
||||
super::super::tick_profile::add(shared.level_id, &kind, started);
|
||||
}
|
||||
let m = self.mobs[i].mob_mut();
|
||||
if seen_guest.is_none() {
|
||||
self.player = w.player;
|
||||
} else if let Some(k) = lent {
|
||||
self.spare = w
|
||||
.player
|
||||
.map(|v| std::mem::replace(&mut self.guests[k].1, v));
|
||||
}
|
||||
// Its tick changed its target: the mods may keep the one it had
|
||||
// (`native_living_change_target`), and then what the tick did to
|
||||
// the new one is not dealt.
|
||||
if let Some(&before) = self.targets.get(i)
|
||||
&& m.ai.target != before
|
||||
{
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&self.guests,
|
||||
shared.guest_ids,
|
||||
self.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Game::native_living_change_target(
|
||||
shared.level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
if let Some(new) = m.ai.target {
|
||||
let mut k = from;
|
||||
while k < self.events.len() {
|
||||
if hits_target(&self.events[k], new) {
|
||||
self.events.remove(k);
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
self.targets[i] = m.ai.target;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
self.corpses.push(m.clone());
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut self.events, from, &mut self.pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
// `AbstractCubeMob.remove` splits a grown cube into its smaller halves when it is
|
||||
// removed, twenty ticks after the death. The parent leaves the list before the split
|
||||
// runs, so its size, position, kind and persistence are captured here, as they are
|
||||
// at the removal.
|
||||
let split = if matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::SulfurCube
|
||||
) && m.extra.size > 1
|
||||
{
|
||||
Some(m.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.gone.push((m.id, m.position, width, height, split));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = shared.guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut self.events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Some(Who::Player)
|
||||
{
|
||||
*target = Some(Who::Mob(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < self.events.len() {
|
||||
if super::super::lan_mobs::acts_on_player(&self.events[k]) {
|
||||
self.guest_events.push((conn, m.id, self.events.remove(k)));
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Killed during its own tick (drowning, burning, falling...).
|
||||
if !m.alive() && !m.removed && self.died.insert(m.id) {
|
||||
self.dead.push(m.clone());
|
||||
}
|
||||
if !kept {
|
||||
self.died.remove(&m.id);
|
||||
}
|
||||
self.keep.push(kept);
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
self.snapshot[i] = seen(m, shared.uuid);
|
||||
self.snapshot[i].alive &= kept;
|
||||
self.grid.update(i, self.snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut self.mobs, &mut self.events, from);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! What the mob loop (`tick_creature_entities`) prepares for every mob before it runs, on the tick's
|
||||
//! helper threads while the parallel tick is on (`horizon_client::parallel`): each answer reads only
|
||||
//! that mob, the blocks and the players, so the helpers give what one thread gives.
|
||||
use super::super::{Game, natural_spawning::NearestPlayers};
|
||||
use horizon_client::mobs::{Mob, MobWorld, Seen};
|
||||
use horizon_client::parallel::par_map;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// From this many mobs a preparation goes to the helper threads.
|
||||
const MIN_MOBS: usize = 256;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.isPushable` reads onClimbable live; a mob's cached one is only refreshed by its
|
||||
/// own push step, so one that has not ticked yet (the first tick after a load, a fresh spawn) or
|
||||
/// has moved since would be pushed out of a ladder or scaffolding. Refreshed here, from where
|
||||
/// each mob stands now; then each mob's entry of the snapshot others push it by goes in
|
||||
/// `snapshot`.
|
||||
pub(super) fn prepare_mobs(&mut self, player: Option<[i32; 4]>, snapshot: &mut Vec<Seen>) {
|
||||
let climbing: Vec<bool> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let rebuild = hookless(&w);
|
||||
par_map(&self.mobs, MIN_MOBS, |part, out| {
|
||||
let w = rebuild();
|
||||
out.extend(part.iter().map(|m| m.on_climbable(&w)));
|
||||
})
|
||||
};
|
||||
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
|
||||
m.on_climbable = climbing;
|
||||
}
|
||||
snapshot.extend(par_map(&self.mobs, MIN_MOBS, |part, out| {
|
||||
out.extend(part.iter().map(|m| super::seen(m, player)));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest player each mob's despawn check reads (`Mob.checkDespawn`), found for every mob before
|
||||
/// the loop. A mob that moved before its turn is asked again where it stands.
|
||||
pub(super) struct MobsNearest<'p> {
|
||||
players: &'p NearestPlayers<'p>,
|
||||
/// Each mob's position as the loop started and its nearest player there; empty while the
|
||||
/// parallel tick is off (every mob asks at its turn).
|
||||
found: Vec<(Vec3, Option<Vec3>)>,
|
||||
}
|
||||
|
||||
impl<'p> MobsNearest<'p> {
|
||||
/// `wanted`: whether the loop asks (not with one player, whose position answers).
|
||||
pub(super) fn new(players: &'p NearestPlayers<'p>, mobs: &[Mob], wanted: bool) -> Self {
|
||||
let found = if wanted && horizon_client::parallel::lanes() > 1 {
|
||||
par_map(mobs, MIN_MOBS, |part, out| {
|
||||
out.extend(
|
||||
part.iter()
|
||||
.map(|m| (m.position, Self::ask(players, m.position))),
|
||||
);
|
||||
})
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Self { players, found }
|
||||
}
|
||||
fn ask(players: &NearestPlayers, at: Vec3) -> Option<Vec3> {
|
||||
players.nearest(at.x, at.y, at.z).map(|p| p.position)
|
||||
}
|
||||
/// The nearest player to mob `i` of the loop, standing at `at`.
|
||||
pub(super) fn at(&self, i: usize, at: Vec3) -> Option<Vec3> {
|
||||
match self.found.get(i) {
|
||||
Some(&(was, nearest)) if was == at => nearest,
|
||||
_ => Self::ask(self.players, at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` without its hooks into the game, which can't leave the server thread, as a recipe a
|
||||
/// helper thread builds its own world from: native mod effects (the phases on helpers don't run
|
||||
/// them) and the UUID lookup.
|
||||
fn hookless<'w>(base: &MobWorld<'w>) -> impl Fn() -> MobWorld<'w> + Sync + use<'w> {
|
||||
// Every field but the two hooks, named, so that a field added to `MobWorld` is decided here.
|
||||
let MobWorld {
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
fluid_tables,
|
||||
movement,
|
||||
collision,
|
||||
suffocation,
|
||||
data,
|
||||
player,
|
||||
controlling_player_movement,
|
||||
nearest_player,
|
||||
others,
|
||||
dragon,
|
||||
leash_boats,
|
||||
others_grid,
|
||||
others_index,
|
||||
others_villagers,
|
||||
others_solid,
|
||||
daylight,
|
||||
monsters_burn,
|
||||
game_time,
|
||||
day_time,
|
||||
raining,
|
||||
thundering,
|
||||
level,
|
||||
block_tags,
|
||||
landing,
|
||||
biomes,
|
||||
mob_griefing,
|
||||
universal_anger,
|
||||
mob_drops,
|
||||
max_entity_cramming,
|
||||
difficulty,
|
||||
dimension_type,
|
||||
world_seed,
|
||||
sea_level,
|
||||
teams,
|
||||
items,
|
||||
lid_openers,
|
||||
mod_effects: _,
|
||||
who_lookup: _,
|
||||
foreign: _,
|
||||
border,
|
||||
} = base.clone();
|
||||
move || MobWorld {
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
fluid_tables,
|
||||
movement,
|
||||
collision,
|
||||
suffocation,
|
||||
data,
|
||||
player: player.clone(),
|
||||
controlling_player_movement,
|
||||
nearest_player,
|
||||
others,
|
||||
dragon,
|
||||
leash_boats,
|
||||
others_grid,
|
||||
others_index,
|
||||
others_villagers,
|
||||
others_solid,
|
||||
daylight,
|
||||
monsters_burn,
|
||||
game_time,
|
||||
day_time,
|
||||
raining,
|
||||
thundering,
|
||||
level,
|
||||
block_tags,
|
||||
landing,
|
||||
biomes,
|
||||
mob_griefing,
|
||||
universal_anger,
|
||||
mob_drops,
|
||||
max_entity_cramming,
|
||||
difficulty,
|
||||
dimension_type,
|
||||
world_seed,
|
||||
sea_level,
|
||||
teams: std::sync::Arc::clone(&teams),
|
||||
items,
|
||||
lid_openers,
|
||||
mod_effects: None,
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
border,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::natural_spawning::LevelPlayer;
|
||||
use super::*;
|
||||
use horizon_core::geometry::Aabb;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn preparation_refreshes_climbing_and_nearest_after_a_move() {
|
||||
let mut game = super::super::super::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for command in [
|
||||
"setblock 8 80 9 stone",
|
||||
"setblock 8 80 8 ladder[facing=north]",
|
||||
"summon pig 8.5 80 8.5 {NoAI:1b}",
|
||||
"summon pig 9.5 80 8.5 {NoAI:1b}",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
assert_eq!(game.mobs.len(), 2);
|
||||
let examples = game.mobs.clone();
|
||||
game.mobs = (0..MIN_MOBS + 37)
|
||||
.map(|i| {
|
||||
let mut mob = examples[i % 2].clone();
|
||||
mob.id = 10_000 + i as u32;
|
||||
mob.on_climbable = i % 2 != 0; // deliberately stale in both cases
|
||||
mob
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
let mut snapshot = Vec::new();
|
||||
game.prepare_mobs(None, &mut snapshot);
|
||||
assert_eq!(snapshot.len(), game.mobs.len());
|
||||
for (i, (m, seen)) in game.mobs.iter().zip(&snapshot).enumerate() {
|
||||
assert_eq!(m.on_climbable, i % 2 == 0, "mob {i}");
|
||||
assert_eq!(seen.id, m.id);
|
||||
assert_eq!(seen.position, m.position);
|
||||
assert_eq!(seen.pushable, i % 2 != 0, "snapshot {i}");
|
||||
}
|
||||
|
||||
let player = |x, spectator| {
|
||||
let position = Vec3::new(x, 80.0, 8.5);
|
||||
LevelPlayer {
|
||||
uuid: 0,
|
||||
position,
|
||||
bounds: Aabb::new(position, position),
|
||||
alive: false, // dead players still count for checkDespawn
|
||||
creative: false,
|
||||
spectator,
|
||||
}
|
||||
};
|
||||
let players = [player(8.5, true), player(7.5, false), player(9.5, false)];
|
||||
let index = NearestPlayers::new(&players);
|
||||
let nearest = MobsNearest::new(&index, &game.mobs, true);
|
||||
for (i, mob) in game.mobs.iter().enumerate() {
|
||||
let expected = if i % 2 == 0 {
|
||||
players[1].position
|
||||
} else {
|
||||
players[2].position
|
||||
};
|
||||
assert_eq!(nearest.at(i, mob.position), Some(expected));
|
||||
// A previous mob's push moved this mob across the nearest-player tie.
|
||||
assert_eq!(
|
||||
nearest.at(i, Vec3::new(10.0, 80.0, 8.5)),
|
||||
Some(players[2].position)
|
||||
);
|
||||
}
|
||||
let no_cache = MobsNearest::new(&index, &game.mobs, false);
|
||||
assert_eq!(
|
||||
no_cache.at(0, game.mobs[0].position),
|
||||
Some(players[1].position)
|
||||
);
|
||||
let spectators = [player(8.5, true)];
|
||||
let index = NearestPlayers::new(&spectators);
|
||||
let nearest = MobsNearest::new(&index, &game.mobs, true);
|
||||
assert_eq!(nearest.at(0, game.mobs[0].position), None);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn in_water(&self, bounds: Aabb) -> bool {
|
||||
self.w.fluids(bounds, bounds.max_position().y).in_water
|
||||
}
|
||||
fn pushed_by_currents(&self, bounds: Aabb, velocity: Vec3) -> Vec3 {
|
||||
self.w.pushed_by_currents(bounds, velocity)
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
@@ -1123,6 +1123,8 @@ impl Game<'_> {
|
||||
let mut stand = horizon_client::entities::ArmorStand::new(id, at, yaw);
|
||||
stand.marker = e["marker"].as_bool().unwrap_or(false);
|
||||
stand.on_ground = e["ground"].as_bool().unwrap_or(false);
|
||||
stand.show_arms = e["arms"].as_bool().unwrap_or(false);
|
||||
stand.disabled_slots = e["disabled"].as_i64().unwrap_or(0) as i32;
|
||||
if let Some(last_hit) = e["last_hit"].as_i64() {
|
||||
stand.last_hit = last_hit;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,14 @@ fn block_box(p: Pos) -> Aabb {
|
||||
Vec3::new(f64::from(p.0 + 1), f64::from(p.1 + 1), f64::from(p.2 + 1)),
|
||||
)
|
||||
}
|
||||
/// `EntitySectionStorage.forEachAccessibleNonEmptySection`: x ascending, then
|
||||
/// the packed key (`SectionPos.asLong`: z as 22 unsigned bits, then y as 20);
|
||||
/// within a section the entities go in the order they joined, which ids follow.
|
||||
fn section_order(at: Vec3) -> (i32, u32, u32) {
|
||||
let s = |v: f64| (v.floor() as i32) >> 4;
|
||||
let (x, y, z) = (s(at.x), s(at.y), s(at.z));
|
||||
(x, z as u32 & 0x3F_FFFF, y as u32 & 0xF_FFFF)
|
||||
}
|
||||
fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
let (a0, a1, b0, b1) = (
|
||||
a.min_position(),
|
||||
@@ -131,6 +139,8 @@ fn optional_event(behaviour: &'static str) -> i32 {
|
||||
#[derive(Clone, Copy)]
|
||||
enum Target {
|
||||
Player,
|
||||
/// A server player, by connection: the integrated owner and every guest.
|
||||
Guest(u32),
|
||||
Mob(usize),
|
||||
Stand(usize),
|
||||
}
|
||||
@@ -459,7 +469,7 @@ impl Game<'_> {
|
||||
r.triangle(0.2, spread),
|
||||
r.triangle(f64::from(s[2]) * pow, spread),
|
||||
];
|
||||
self.spawn_item(stack, at, v, 0);
|
||||
self.spawn_item_thrown(stack, at, v);
|
||||
}
|
||||
/// `DefaultDispenseItemBehavior.execute`: drop one item.
|
||||
fn default_execute(
|
||||
@@ -719,34 +729,57 @@ impl Game<'_> {
|
||||
|
||||
// ---- entities ----
|
||||
|
||||
/// Entities whose box overlaps the block at `p`, in the order the level
|
||||
/// added them (an entity section keeps insertion order; ids grow with it).
|
||||
/// Entities whose box overlaps the block at `p`, in `getEntitiesOfClass`
|
||||
/// order ([`section_order`]): players are entities of the level like any
|
||||
/// other, so they sort with the mobs and stands.
|
||||
fn targets_at(&self, p: Pos) -> Vec<Target> {
|
||||
let block = block_box(p);
|
||||
let mut found: Vec<(u32, Target)> = self
|
||||
let key = |at: Vec3, id: u32| (section_order(at), id);
|
||||
let mut found: Vec<(_, Target)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| !m.removed && overlaps(m.bounds(), block))
|
||||
.map(|(i, m)| (m.id, Target::Mob(i)))
|
||||
.map(|(i, m)| (key(m.position, m.id), Target::Mob(i)))
|
||||
.collect();
|
||||
found.extend(
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| overlaps(s.bounds(), block))
|
||||
.map(|(i, s)| (s.id, Target::Stand(i))),
|
||||
.map(|(i, s)| (key(s.position, s.id), Target::Stand(i))),
|
||||
);
|
||||
found.sort_by_key(|(id, _)| *id);
|
||||
let mut out: Vec<Target> = found.into_iter().map(|(_, t)| t).collect();
|
||||
// `ServerLevel.getEntities`: every player placed in this level, the
|
||||
// integrated owner included (a server has no player of its own), its
|
||||
// client loaded or not (it joins the level at placeNewPlayer/respawn).
|
||||
if let Some(host) = &self.lan {
|
||||
found.extend(
|
||||
(host.hub.guests.iter())
|
||||
.filter(|g| host.hub.here(g) && !g.dead && !g.spectator())
|
||||
.filter(|g| overlaps(g.bounds(), block))
|
||||
.map(|g| {
|
||||
(
|
||||
key(
|
||||
Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
g.entity_id as u32,
|
||||
),
|
||||
Target::Guest(g.conn),
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(v) = self.player_view()
|
||||
&& v.alive
|
||||
&& !self.spectator()
|
||||
&& overlaps(v.bounds, block)
|
||||
{
|
||||
out.push(Target::Player);
|
||||
// The local player joins before the chunks around it load their
|
||||
// entities (prepareLevels loads only saved tickets): the lowest id,
|
||||
// 0 as `own_entity_id` has it.
|
||||
found.push((key(v.position, 0), Target::Player));
|
||||
}
|
||||
out
|
||||
found.sort_by_key(|(k, _)| *k);
|
||||
found.into_iter().map(|(_, t)| t).collect()
|
||||
}
|
||||
/// `EntityType.getYOffset(level, pos, false, box)`: [`Self::drop_offset_from`] without
|
||||
/// `movedUp`.
|
||||
@@ -851,6 +884,22 @@ impl Game<'_> {
|
||||
self.spawn_jockeys(id, false);
|
||||
Some(id)
|
||||
}
|
||||
/// `Player.setItemSlot` of a dispensed piece for the player in place:
|
||||
/// `onEquipItem` plays the sound and the event; the modifiers follow.
|
||||
fn put_on_player(&mut self, slot: Slot, one: &ItemStack) {
|
||||
let name = match slot.armor_index() {
|
||||
Some(i) => {
|
||||
self.armor[i] = Some(one.clone());
|
||||
["head", "chest", "legs", "feet"][i]
|
||||
}
|
||||
None => {
|
||||
self.offhand = Some(one.clone());
|
||||
"offhand"
|
||||
}
|
||||
};
|
||||
self.player_equipped(name, Some(one));
|
||||
self.apply_equipment();
|
||||
}
|
||||
/// `EquipmentDispenseItemBehavior.dispenseEquipment`: the first living
|
||||
/// entity in front that `canEquipWithDispenser` gets one of the item.
|
||||
fn dispense_equipment(&mut self, pos: Pos, facing: Direction, stack: &mut ItemStack) -> bool {
|
||||
@@ -887,8 +936,23 @@ impl Game<'_> {
|
||||
_ => slot.armor_index().is_some_and(|i| self.armor[i].is_none()),
|
||||
}
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
allows("minecraft:player")
|
||||
&& self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.hub.guest(conn))
|
||||
.is_some_and(|g| match slot {
|
||||
Slot::OffHand => g.offhand.is_none(),
|
||||
_ => slot.armor_index().is_some_and(|i| g.armor[i].is_none()),
|
||||
})
|
||||
}
|
||||
// ArmorStand.canUseSlot: not a locked slot, and a hand needs arms.
|
||||
Target::Stand(i) => slot.stand_index().is_some_and(|s| {
|
||||
allows("minecraft:armor_stand") && self.armor_stands[i].equipment[s].is_none()
|
||||
let stand = &self.armor_stands[i];
|
||||
allows("minecraft:armor_stand")
|
||||
&& !stand.is_disabled(s)
|
||||
&& stand.equipment[s].is_none()
|
||||
}),
|
||||
Target::Mob(i) => {
|
||||
let m = &self.mobs[i];
|
||||
@@ -905,20 +969,28 @@ impl Game<'_> {
|
||||
stack.count -= 1;
|
||||
// setItemSlot: onEquipItem plays the equip sound and the equip event.
|
||||
let (at, sound) = match target {
|
||||
Target::Guest(conn) => {
|
||||
// The guest's own turn: their state in place, so the slot, the
|
||||
// sound, the game event and the equipment sync run as for the host.
|
||||
let done = self
|
||||
.for_player(super::commands::Target::Guest(conn), |g| {
|
||||
g.put_on_player(slot, &one)
|
||||
})
|
||||
.is_some();
|
||||
if !done {
|
||||
stack.count += 1;
|
||||
return false;
|
||||
}
|
||||
self.native_block_dispense_armor(
|
||||
self.dim.id(),
|
||||
[pos.0, pos.1, pos.2],
|
||||
&stack.item,
|
||||
"player",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
Target::Player => {
|
||||
// Player.setItemSlot: onEquipItem plays the sound and the event.
|
||||
let name = match slot.armor_index() {
|
||||
Some(i) => {
|
||||
self.armor[i] = Some(one.clone());
|
||||
["head", "chest", "legs", "feet"][i]
|
||||
}
|
||||
None => {
|
||||
self.offhand = Some(one.clone());
|
||||
"offhand"
|
||||
}
|
||||
};
|
||||
self.player_equipped(name, Some(&one));
|
||||
self.apply_equipment();
|
||||
self.put_on_player(slot, &one);
|
||||
self.native_block_dispense_armor(
|
||||
self.dim.id(),
|
||||
[pos.0, pos.1, pos.2],
|
||||
@@ -953,7 +1025,7 @@ impl Game<'_> {
|
||||
}
|
||||
let who = match target {
|
||||
Target::Mob(i) => Some(horizon_client::mobs::Who::Mob(self.mobs[i].id)),
|
||||
Target::Player | Target::Stand(_) => None,
|
||||
Target::Player | Target::Guest(_) | Target::Stand(_) => None,
|
||||
};
|
||||
let ctx = self.who_context(who, None);
|
||||
self.post_game_event("minecraft:equip", [at.x, at.y, at.z], ctx);
|
||||
@@ -965,7 +1037,7 @@ impl Game<'_> {
|
||||
match target {
|
||||
Target::Stand(_) => "armor_stand",
|
||||
Target::Mob(_) => "mob",
|
||||
Target::Player => "player",
|
||||
Target::Player | Target::Guest(_) => "player",
|
||||
},
|
||||
);
|
||||
true
|
||||
@@ -987,22 +1059,35 @@ impl Game<'_> {
|
||||
self.mobs.insert(i, m);
|
||||
self.handle_mob_events(events)
|
||||
}
|
||||
/// A mob a bucket emptied: `setFromBucket` keeps it, then it calls out.
|
||||
/// Fish and axolotls carry the flag itself (saved as `FromBucket`, read by
|
||||
/// `requiresCustomPersistence`); the others stay persistent instead.
|
||||
pub(super) fn mob_placed(&mut self, id: u32) -> Result<()> {
|
||||
/// A mob a bucket emptied (`MobBucketItem.spawn`): the bucket's components
|
||||
/// and entity data (`loadFromBucketTag`), then `setFromBucket(true)`, which
|
||||
/// `requiresCustomPersistence` reads, then it calls out. Only the bucket's
|
||||
/// data can make it persistent, not the spawn: `spawn_mob_at` creates a
|
||||
/// persistent mob. A tadpole's setter does nothing: its `fromBucket` is
|
||||
/// always true.
|
||||
pub(super) fn mob_placed(&mut self, id: u32, stack: &ItemStack) -> Result<()> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
if matches!(
|
||||
m.species(),
|
||||
m.persistent = false;
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
match m.species() {
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::Pufferfish
|
||||
| Species::TropicalFish
|
||||
| Species::Axolotl
|
||||
) {
|
||||
m.extra.from_bucket = true;
|
||||
} else {
|
||||
m.persistent = true;
|
||||
| Species::Salmon
|
||||
| Species::Pufferfish
|
||||
| Species::TropicalFish
|
||||
| Species::Axolotl => m.extra.from_bucket = true,
|
||||
Species::SulfurCube => {
|
||||
// SulfurCube.loadFromBucketTag: its own lowercase keys, 0
|
||||
// and false when absent, whatever `Age` the data holds.
|
||||
let data = stack
|
||||
.data()
|
||||
.and_then(|c| c.other("minecraft:bucket_entity_data"));
|
||||
let age = data.and_then(|d| horizon_client::save::number(d.get("age")));
|
||||
m.extra.age = age.unwrap_or(0) as i32;
|
||||
m.extra.age_locked =
|
||||
data.is_some_and(|d| horizon_client::entity_nbt::flag(d, "age_locked"));
|
||||
m.extra.scratch[3] = 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.mob_ambient(id)
|
||||
@@ -1353,13 +1438,10 @@ impl Game<'_> {
|
||||
self.evaporation_smoke([target.0, target.1, target.2]);
|
||||
}
|
||||
if let Some(kind) = mob {
|
||||
// MobBucketItem.checkExtraContent: the mob (from the bucket, so
|
||||
// persistent) calls out, then ENTITY_PLACE.
|
||||
// MobBucketItem.checkExtraContent: the mob from the bucket calls
|
||||
// out, then ENTITY_PLACE.
|
||||
if let Some(id) = self.spawn_mob_at(kind, target, true) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, &stack);
|
||||
}
|
||||
self.mob_placed(id)?;
|
||||
self.mob_placed(id, &stack)?;
|
||||
}
|
||||
self.post_block_game_event(
|
||||
"minecraft:entity_place",
|
||||
@@ -2083,7 +2165,7 @@ impl Game<'_> {
|
||||
};
|
||||
(Holder::Player, v.bounds.center(), None)
|
||||
}
|
||||
Target::Stand(_) => return false,
|
||||
Target::Guest(_) | Target::Stand(_) => return false,
|
||||
};
|
||||
let area = Aabb::new(centre, centre).inflate(Vec3::new(16.0, 16.0, 16.0));
|
||||
let mut leashed: Vec<(u32, usize)> = self
|
||||
@@ -2334,4 +2416,229 @@ mod tests {
|
||||
Some((None, 0))
|
||||
);
|
||||
}
|
||||
|
||||
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
/// A server with no player of its own, an open hub and one guest (conn 2)
|
||||
/// standing at (8.5, 200, 8.5) on a stone floor.
|
||||
fn server_with_guest() -> Game<'static> {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
game.lan_host(
|
||||
super::super::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "dispenser-equip-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 208,
|
||||
name: "Wearer".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[8.5, 200.0, 8.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
game.lan.as_mut().unwrap().hub.guests.push(guest);
|
||||
game
|
||||
}
|
||||
|
||||
fn dispense_at_guest(game: &mut Game<'_>, item: &str) -> (bool, u32) {
|
||||
let mut stack = ItemStack::new(item, 2);
|
||||
// The dispenser sits one block over the guest's feet, facing down.
|
||||
let done = game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack);
|
||||
(done, stack.count)
|
||||
}
|
||||
|
||||
/// `EquipmentDispenseItemBehavior`: a server's player, the integrated
|
||||
/// owner or a guest, takes the piece (a server has no player of its own).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_equips_a_server_player_in_front() {
|
||||
let mut game = server_with_guest();
|
||||
assert!(game.player.is_none(), "the server thread has no player");
|
||||
// The guest's feet are in block (8, 200, 8): the dispenser at y 201
|
||||
// facing down targets the block under it.
|
||||
let mut stack = ItemStack::new("minecraft:carved_pumpkin", 3);
|
||||
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert_eq!(stack.count, 2);
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
|
||||
assert_eq!(
|
||||
guest.armor[0].as_ref().map(|s| (&*s.item, s.count)),
|
||||
Some(("minecraft:carved_pumpkin", 1))
|
||||
);
|
||||
// The head is taken now: a second pumpkin is not dispensed onto them.
|
||||
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert_eq!(stack.count, 2);
|
||||
// A boots piece still has its own empty slot.
|
||||
let (done, left) = dispense_at_guest(&mut game, "minecraft:iron_boots");
|
||||
assert_eq!((done, left), (true, 1), "boots go on the free feet slot");
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
|
||||
assert!(guest.armor[3].is_some() && guest.armor[1].is_none());
|
||||
}
|
||||
|
||||
/// A spectator is not a target (`canEquipWithDispenser`: `!isSpectator()`).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_skips_a_spectator_and_a_dead_player() {
|
||||
let mut game = server_with_guest();
|
||||
for spectate in [true, false] {
|
||||
let g = &mut game.lan.as_mut().unwrap().hub.guests[0];
|
||||
g.game_mode = if spectate { 3 } else { 0 };
|
||||
g.dead = !spectate;
|
||||
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert_eq!(stack.count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ArmorStand.canUseSlot` through `isDisabled`: the first stand in front
|
||||
/// that can wear the piece gets it. Bit `1 << filterBit` locks a slot
|
||||
/// (head 4, chest 3, legs 2, feet 1); a hand needs arms.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_skips_an_armor_stand_whose_slot_is_locked() {
|
||||
use horizon_client::entities::ArmorStand;
|
||||
let mut game = server_with_guest();
|
||||
game.lan.as_mut().unwrap().hub.guests.clear();
|
||||
let at = horizon_core::geometry::Vec3::new(8.5, 200.0, 8.5);
|
||||
let mut stands = [(1, 1u32 << 4), (2, 0)].map(|(id, locked)| {
|
||||
let mut s = ArmorStand::new(id, at, 0.0);
|
||||
s.disabled_slots = locked as i32;
|
||||
s
|
||||
});
|
||||
stands[0].show_arms = true;
|
||||
game.armor_stands = stands.to_vec().into();
|
||||
// The head-locked first stand is skipped for a helmet; the second wears it.
|
||||
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert!(game.armor_stands[0].equipment[5].is_none());
|
||||
assert!(game.armor_stands[1].equipment[5].is_some());
|
||||
// The same stand takes chest armour: only its head is locked.
|
||||
let mut stack = ItemStack::new("minecraft:iron_chestplate", 1);
|
||||
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert!(game.armor_stands[0].equipment[4].is_some());
|
||||
// Every stand locked for that slot: nothing takes it (the dispenser ejects it).
|
||||
game.armor_stands[0].disabled_slots = 1 << 4;
|
||||
game.armor_stands[1].disabled_slots = 1 << 4;
|
||||
game.armor_stands[1].equipment[5] = None;
|
||||
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert_eq!(stack.count, 1);
|
||||
}
|
||||
|
||||
/// A stand of entity id `id` standing at `x`, 200, `z`.
|
||||
fn stand_at(id: u32, x: f64, z: f64) -> horizon_client::entities::ArmorStand {
|
||||
let at = horizon_core::geometry::Vec3::new(x, 200.0, z);
|
||||
horizon_client::entities::ArmorStand::new(id, at, 0.0)
|
||||
}
|
||||
|
||||
/// A guest whose client is still on its loading screen (joined or
|
||||
/// respawned, no ServerboundPlayerLoadedPacket yet) is in the level all the
|
||||
/// same: `getEntitiesOfClass` finds it and it wears the piece.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_equips_a_player_whose_client_is_still_loading() {
|
||||
let mut game = server_with_guest();
|
||||
let g = &mut game.lan.as_mut().unwrap().hub.guests[0];
|
||||
(g.loaded, g.loading) = (false, true);
|
||||
let (done, left) = dispense_at_guest(&mut game, "minecraft:iron_helmet");
|
||||
assert_eq!((done, left), (true, 1));
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
|
||||
let head = guest.armor[0].as_ref().map(|s| &*s.item);
|
||||
assert_eq!(head, Some("minecraft:iron_helmet"));
|
||||
}
|
||||
|
||||
/// Players are entities of the level: in one section they go in join
|
||||
/// order (entity ids) with the mobs and stands, not after them.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_player_and_a_stand_in_front_go_in_join_order() {
|
||||
// (stand id, guest id) -> who wears the helmet: the lower id joined first.
|
||||
for (stand_id, guest_id, guest_first) in [(7, 5, true), (3, 5, false)] {
|
||||
let mut game = server_with_guest();
|
||||
game.lan.as_mut().unwrap().hub.guests[0].entity_id = guest_id;
|
||||
game.armor_stands = vec![stand_at(stand_id, 8.5, 8.5)].into();
|
||||
let (done, _) = dispense_at_guest(&mut game, "minecraft:iron_helmet");
|
||||
assert!(done);
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
|
||||
assert_eq!(
|
||||
guest.armor[0].is_some(),
|
||||
guest_first,
|
||||
"stand {stand_id}, guest {guest_id}"
|
||||
);
|
||||
assert_eq!(game.armor_stands[0].equipment[5].is_some(), !guest_first);
|
||||
}
|
||||
}
|
||||
|
||||
/// `forEachAccessibleNonEmptySection` walks sections before join order:
|
||||
/// a stand whose feet are in section x 1 but whose box reaches into block
|
||||
/// x 15 comes after one in section x 0, even with the lower id.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_walks_entity_sections_before_ids() {
|
||||
let mut game = server_with_guest();
|
||||
game.lan.as_mut().unwrap().hub.guests.clear();
|
||||
// Stand boxes are 0.5 wide: x 16.2 spans 15.95..16.45.
|
||||
game.armor_stands = vec![stand_at(1, 16.2, 8.5), stand_at(2, 15.5, 8.5)].into();
|
||||
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
assert!(game.dispense_equipment((15, 201, 8), Direction::Down, &mut stack));
|
||||
assert!(game.armor_stands[0].equipment[5].is_none());
|
||||
assert!(game.armor_stands[1].equipment[5].is_some());
|
||||
}
|
||||
|
||||
/// `SectionPos.asLong` packs z (22 bits) over y (20 bits) under a fixed x,
|
||||
/// and the section set is sorted as longs: z -1 packs as 0x3FFFFF << 20,
|
||||
/// above z 0's 0; y -1 at z 0 packs as 0xFFFFF, below z 1's 1 << 20.
|
||||
#[test]
|
||||
fn sections_sort_as_their_packed_keys() {
|
||||
use horizon_core::geometry::Vec3;
|
||||
let at = |x: f64, y: f64, z: f64| super::section_order(Vec3::new(x, y, z));
|
||||
assert!(at(0.0, 0.0, -1.0) > at(0.0, 0.0, 0.0));
|
||||
assert!(at(0.0, -1.0, 0.0) < at(0.0, 0.0, 16.0));
|
||||
assert!(at(0.0, -1.0, 0.0) > at(0.0, 0.0, 0.0));
|
||||
// x is the outer loop, ascending as a signed coordinate.
|
||||
assert!(at(-1.0, 0.0, 0.0) < at(0.0, 0.0, 0.0));
|
||||
assert!(at(15.9, 0.0, 0.0) < at(16.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -285,3 +285,109 @@ fn a_teleporting_shulker_leaves_its_rider_behind() {
|
||||
}
|
||||
assert!(teleported > 0, "no shulker teleported");
|
||||
}
|
||||
|
||||
/// `Shulker.teleportSomewhere`: the teleport posts `GameEvent.TELEPORT` at
|
||||
/// the block it left, between its sound and level event 2016, so a sculk
|
||||
/// sensor where it stood hears the shulker arrive.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_teleporting_shulker_posts_teleport_where_it_stood() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let mut game = floored_game(&root);
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
// A sensor just above the shulker hears the vibration where it stands.
|
||||
game.sculk_prepare();
|
||||
assert!(game.run_command("setblock 8 5 8 sculk_sensor"));
|
||||
game.sculk_sync_index();
|
||||
let at = Vec3::new(8.5, 4.0, 8.5);
|
||||
let old = [at.x, at.y, at.z];
|
||||
// `LevelAccessor.gameEvent(Holder, BlockPos, ..)` centers the block.
|
||||
let old_center = [at.x, at.y + 0.5, at.z];
|
||||
let mut teleported = false;
|
||||
for seed in 0..40 {
|
||||
let id = 900 + seed as u32;
|
||||
let mut mob = shulker(&game, id, at, seed);
|
||||
// What `after_hurt` leaves for a low-health teleport.
|
||||
mob.extra.flag = true;
|
||||
let uuid = game.level.player_uuid;
|
||||
let others: Vec<_> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| super::creatures::seen(m, uuid))
|
||||
.collect();
|
||||
let mut level_random = game.world.random.clone();
|
||||
let mut events = Vec::new();
|
||||
{
|
||||
let world = game.mob_world_base(&others);
|
||||
horizon_client::mobs::shulker::run_pending_teleports(
|
||||
&mut mob,
|
||||
&world,
|
||||
&mut level_random,
|
||||
&mut events,
|
||||
);
|
||||
}
|
||||
if mob.position.distance(at) <= 0.5 {
|
||||
continue;
|
||||
}
|
||||
teleported = true;
|
||||
let sound = events.iter().position(|e| {
|
||||
matches!(e, MobEvent::Sound(name, p, ..) if *name == "entity.shulker.teleport" && *p == old)
|
||||
});
|
||||
let teleport = events.iter().position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
MobEvent::GameEvent { event, at: p, source, .. }
|
||||
if *event == "minecraft:teleport" && *p == old_center && *source == id
|
||||
)
|
||||
});
|
||||
let level = events.iter().position(
|
||||
|e| matches!(e, MobEvent::LevelEvent { id: 2016, pos, .. } if *pos == [8, 4, 8]),
|
||||
);
|
||||
assert!(
|
||||
sound.is_some(),
|
||||
"seed {seed}: no teleport sound: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
teleport.is_some(),
|
||||
"seed {seed}: no minecraft:teleport game event: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
level.is_some(),
|
||||
"seed {seed}: no level event 2016: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
sound < teleport && teleport < level,
|
||||
"seed {seed}: the game event is not between the sound and level event: {events:?}"
|
||||
);
|
||||
game.mobs.push(mob);
|
||||
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
game.handle_mob_events(events).unwrap();
|
||||
let posted: Vec<_> = super::sculk::TEST_POSTED_EVENTS.with(|events| {
|
||||
events
|
||||
.borrow_mut()
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|(event, _, _)| *event == "minecraft:teleport")
|
||||
.map(|(_, pos, source)| (pos, source))
|
||||
.collect()
|
||||
});
|
||||
assert_eq!(
|
||||
posted,
|
||||
[(old_center, Some(horizon_client::mobs::Who::Mob(id)))],
|
||||
"seed {seed}: the teleport event is posted from the shulker at its old position"
|
||||
);
|
||||
// The sensor above the old block holds that vibration.
|
||||
game.chat_capture = Some(Vec::new());
|
||||
assert!(game.run_command("data get block 8 5 8 listener.selector.event"));
|
||||
let said = game.chat_capture.take().unwrap_or_default();
|
||||
let said: String = said.iter().map(horizon_client::text::Text::plain).collect();
|
||||
assert!(
|
||||
said.contains("minecraft:teleport") && said.contains("[8.5d, 4.5d, 8.5d]"),
|
||||
"seed {seed}: the sensor did not hear the teleport: {said}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
assert!(teleported, "no shulker teleported");
|
||||
}
|
||||
@@ -262,6 +262,14 @@ pub(super) trait Entity: EntityRules {
|
||||
fn is_vehicle(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `Entity.getDimensionChangingDelay`, overridden by vehicles and projectiles.
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
horizon_client::nether_portal::ENTITY_DELAY
|
||||
}
|
||||
/// Whether movement applies portal block effects before Entity's base tick.
|
||||
fn portal_after_move(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Where it stood at the start of its tick, for the kinds that store it
|
||||
/// (None: `portals.before` has it).
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
@@ -323,7 +331,14 @@ impl RenderEntityBounds for Mob {
|
||||
self.species(),
|
||||
Species::Guardian | Species::ElderGuardian | Species::Phantom
|
||||
) {
|
||||
let radius = width.max(height) * 2.5 + 3.0;
|
||||
// The model's root scales its appendages too (the elder guardian
|
||||
// uses 2.35), so the margin must grow along with the model.
|
||||
let root_scale = self.kind.model.pose[6..]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(f32::abs)
|
||||
.fold(1.0, f32::max);
|
||||
let radius = width.max(height) * 2.5 + 3.0 * f64::from(root_scale * self.model_scale());
|
||||
return Aabb::of_size(at, Vec3::new(radius * 2.0, radius * 2.0, radius * 2.0));
|
||||
}
|
||||
|
||||
@@ -599,24 +614,9 @@ impl Game<'_> {
|
||||
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
|
||||
) -> (bool, bool) {
|
||||
let no_physics = escape_speed.is_some();
|
||||
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
// Entity.baseTick splashes before moving, except on the first tick.
|
||||
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
|
||||
item.lava_fall(heights.1 > 0.0);
|
||||
let step = item.tick_move_before_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
no_physics,
|
||||
escape_speed,
|
||||
heights,
|
||||
entity_boxes,
|
||||
Some(self.border.geometry()),
|
||||
Some(&self.res.motion().blocks),
|
||||
);
|
||||
item.check_fall(&step, |fall, from, moved| {
|
||||
self.vehicle_fall_reset(fall, from, moved)
|
||||
});
|
||||
let step = self
|
||||
.item_tables()
|
||||
.move_item(item, escape_speed, entity_boxes, splash);
|
||||
if let Some(pos) = step.vertical_contact {
|
||||
let state = self.world.block(pos.x, pos.y, pos.z);
|
||||
let position = item.position;
|
||||
@@ -635,23 +635,9 @@ impl Game<'_> {
|
||||
return (false, true);
|
||||
}
|
||||
}
|
||||
item.finish_move_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
no_physics,
|
||||
Some(&self.res.motion().blocks),
|
||||
step,
|
||||
);
|
||||
item.push_by_bubble_column(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
let alive = item.tick_settle(&self.world, self.res.facts(), no_physics, step);
|
||||
// ItemEntity.tick refreshes immersion and splashes on water entry.
|
||||
let (water, _) = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
item.water_entry(water > 0.0, false, splash);
|
||||
let alive = self
|
||||
.item_tables()
|
||||
.settle_item(item, no_physics, step, splash);
|
||||
(alive, false)
|
||||
}
|
||||
|
||||
@@ -1322,6 +1308,9 @@ impl Entity for ExperienceOrb {
|
||||
}
|
||||
|
||||
impl Entity for Boat {
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
10
|
||||
}
|
||||
fn set_on_ground(&mut self, on_ground: bool) {
|
||||
self.on_ground = on_ground;
|
||||
}
|
||||
@@ -1397,6 +1386,9 @@ impl Entity for Boat {
|
||||
}
|
||||
|
||||
impl Entity for Minecart {
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
10
|
||||
}
|
||||
fn set_on_ground(&mut self, on_ground: bool) {
|
||||
self.on_ground = on_ground;
|
||||
}
|
||||
@@ -1723,6 +1715,16 @@ impl Entity for FallingBlock {
|
||||
}
|
||||
|
||||
impl Entity for Projectile {
|
||||
fn portal_after_move(&self) -> bool {
|
||||
!matches!(
|
||||
self.kind,
|
||||
horizon_client::projectiles::ProjectileKind::LlamaSpit
|
||||
| horizon_client::projectiles::ProjectileKind::ShulkerBullet { .. }
|
||||
)
|
||||
}
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
@@ -1843,6 +1845,9 @@ impl Entity for Projectile {
|
||||
}
|
||||
|
||||
impl Entity for Rocket {
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
self.rotation
|
||||
}
|
||||
@@ -1910,6 +1915,12 @@ impl Entity for Rocket {
|
||||
}
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
fn portal_after_move(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
|
||||
@@ -1130,7 +1130,8 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
|
||||
let custom = self.world.block_names.get(&key).cloned();
|
||||
self.pending.record(pos, &entity, &name, custom);
|
||||
self.pending
|
||||
.record(pos, &entity, &name, custom, self.world.block_lock(key));
|
||||
} else if let Some(tag) = super::spill::saved_entity(&self.world, pos) {
|
||||
self.pending.record_saved(pos, &tag);
|
||||
}
|
||||
@@ -1793,3 +1794,158 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shulker_lock_drop_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::{containers::BlockEntity, save};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn destruction_captures_the_external_shulker_lock_before_removal() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(
|
||||
game.ui.rules.bool("block_drops"),
|
||||
"actual drop rule must be enabled"
|
||||
);
|
||||
game.creative = false;
|
||||
game.selected = 0;
|
||||
game.inventory[0] = Some(ItemStack::new("minecraft:diamond_pickaxe", 1));
|
||||
let clock = game.world.game_time;
|
||||
let mut missing = Vec::new();
|
||||
for (x, z, kind, locked, route) in [
|
||||
(3, 3, "shulker_box", true, "command"),
|
||||
(11, 3, "shulker_box", true, "player"),
|
||||
(3, 11, "shulker_box", true, "explosion"),
|
||||
(11, 11, "red_shulker_box", false, "command"),
|
||||
(7, 3, "chest", true, "command"),
|
||||
(7, 11, "crafter", true, "command"),
|
||||
] {
|
||||
let shulker = kind.ends_with("shulker_box");
|
||||
let mut fields = Vec::new();
|
||||
if shulker {
|
||||
fields.push("CustomName:\"Box\"");
|
||||
fields.push("Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h211:1}}},{Slot:26b,id:\"minecraft:apple\",count:5}]");
|
||||
}
|
||||
if locked {
|
||||
fields.push("lock:{items:\"minecraft:diamond\"}");
|
||||
}
|
||||
assert!(game.run_command(&format!(
|
||||
"setblock {x} 200 {z} minecraft:{kind}{{{}}}",
|
||||
fields.join(",")
|
||||
)));
|
||||
let pos = [x, 200, z];
|
||||
let key = (x, 200, z);
|
||||
assert_eq!(
|
||||
&*game.res.facts().get(game.world.block(x, 199, z)).name,
|
||||
"minecraft:stone",
|
||||
"positive floor"
|
||||
);
|
||||
let actual_lock = game.world.block_lock(key).cloned();
|
||||
assert_eq!(actual_lock.is_some(), locked, "actual external source lock");
|
||||
if locked {
|
||||
assert!(matches!(&actual_lock,Some(Tag::Compound(c)) if !c.is_empty()));
|
||||
}
|
||||
let entity = game
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.expect("actual command-created source");
|
||||
assert!(
|
||||
save::encode_block_entity(key, entity, &format!("minecraft:{kind}"))
|
||||
.get("lock")
|
||||
.is_none(),
|
||||
"external lock must not be invented in modeled serializer"
|
||||
);
|
||||
if shulker {
|
||||
let BlockEntity::Chest(slots) = entity else {
|
||||
panic!("modeled shulker inventory missing");
|
||||
};
|
||||
assert_eq!(slots[0].as_ref().unwrap().count, 3);
|
||||
assert_eq!(slots[26].as_ref().unwrap().count, 5);
|
||||
assert_eq!(
|
||||
game.world.block_names.get(&key),
|
||||
Some(&Tag::String("Box".into()))
|
||||
);
|
||||
}
|
||||
let start = game.dropped.len();
|
||||
match route {
|
||||
"command" => {
|
||||
assert!(game.run_command(&format!("setblock {x} 200 {z} air destroy")))
|
||||
}
|
||||
"player" => game.break_block(pos).unwrap(),
|
||||
"explosion" => game
|
||||
.server_explode(
|
||||
pos.map(|v| f64::from(v) + 0.5),
|
||||
2.0,
|
||||
BlockInteraction::Destroy,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
game.flush_spill();
|
||||
assert!(
|
||||
game.res.facts().is_air(game.world.block(x, 200, z)),
|
||||
"actual destruction occurred: {route}"
|
||||
);
|
||||
assert!(!game.world.block_entities.contains_key(&key));
|
||||
assert!(
|
||||
game.world.block_lock(key).is_none(),
|
||||
"actual external source was removed"
|
||||
);
|
||||
let item_id = format!("minecraft:{kind}");
|
||||
let drops: Vec<_> = game.dropped[start..]
|
||||
.iter()
|
||||
.filter(|d| *d.stack.item == item_id)
|
||||
.collect();
|
||||
assert_eq!(drops.len(), 1, "actual one-item drop: {kind}/{route}");
|
||||
let stack = &drops[0].stack;
|
||||
assert_eq!(stack.count, 1);
|
||||
let data = stack.data();
|
||||
let dropped_lock = data.and_then(|d| d.other("minecraft:lock"));
|
||||
if shulker {
|
||||
let data = data.expect("actual shulker components");
|
||||
assert_eq!(data.custom_name, Some(Tag::String("Box".into())));
|
||||
let slots = &data.container;
|
||||
assert_eq!(slots.len(), 2);
|
||||
let diamond = &slots.iter().find(|(slot, _)| *slot == 0).unwrap().1;
|
||||
assert_eq!((&*diamond.item, diamond.count), ("minecraft:diamond", 3));
|
||||
assert_eq!(
|
||||
diamond
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:custom_data"))
|
||||
.and_then(|t| t.get("h211")),
|
||||
Some(&Tag::Int(1))
|
||||
);
|
||||
assert_eq!(
|
||||
slots.iter().find(|(slot, _)| *slot == 26).unwrap().1.count,
|
||||
5
|
||||
);
|
||||
}
|
||||
if shulker && locked {
|
||||
if dropped_lock != actual_lock.as_ref() {
|
||||
missing.push((route, dropped_lock.cloned(), actual_lock));
|
||||
}
|
||||
} else {
|
||||
assert!(
|
||||
dropped_lock.is_none(),
|
||||
"non-shulker/unlocked policy unchanged"
|
||||
);
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock, "zero observation ticks");
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"actual external lock lost at destruction capture sites: {missing:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,66 @@
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Cache only model-dependent work, never an entity's seed, light or pose.
|
||||
pub(super) fn falling_model_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
start_pos: BlockPos,
|
||||
origin: glam::Vec3,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// `FallingBlockRenderer`: the model is picked by the block's starting position.
|
||||
let seed = horizon_core::random::position_seed(start_pos);
|
||||
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
|
||||
return;
|
||||
};
|
||||
// Baked models live at stable addresses until the pack is replaced.
|
||||
// Keep multipart order and duplicates: both affect the emitted vertices.
|
||||
let mut cache = self.falling_vertices.borrow_mut();
|
||||
let entries = cache.entry(state).or_default();
|
||||
let index = entries.iter().position(|(ptrs, _)| {
|
||||
ptrs.iter()
|
||||
.copied()
|
||||
.eq(models.iter().map(|(_, m)| *m as *const _ as usize))
|
||||
});
|
||||
let index = index.unwrap_or_else(|| {
|
||||
let mut vertices = Vec::new();
|
||||
self.block_model_vertices(state, seed, glam::Mat4::IDENTITY, 0, false, &mut vertices);
|
||||
// Preserve the original coordinates, including signed zero, rather
|
||||
// than composing an identity transform with the frame's translation.
|
||||
let positions = models.iter().flat_map(|(_, model)| {
|
||||
model
|
||||
.faces
|
||||
.iter()
|
||||
.filter(|face| {
|
||||
self.pack
|
||||
.layer_names
|
||||
.get(&face.layer)
|
||||
.is_some_and(|name| self.gui.atlas.sprites.contains_key(name))
|
||||
})
|
||||
.flat_map(|face| face.quad.positions)
|
||||
});
|
||||
for (vertex, pos) in vertices.iter_mut().zip(positions) {
|
||||
vertex.pos = pos;
|
||||
}
|
||||
let ptrs = models
|
||||
.iter()
|
||||
.map(|(_, m)| *m as *const _ as usize)
|
||||
.collect();
|
||||
entries.push((ptrs, vertices));
|
||||
entries.len() - 1
|
||||
});
|
||||
let start = out.len();
|
||||
out.extend_from_slice(&entries[index].1);
|
||||
let matrix = glam::Mat4::from_translation(origin);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.pos = matrix
|
||||
.transform_point3(glam::Vec3::from_array(vertex.pos))
|
||||
.to_array();
|
||||
vertex.light = light;
|
||||
}
|
||||
}
|
||||
/// FallingBlockEntity.tick: fall with 0.04 gravity and 0.98F drag, then land.
|
||||
/// One falling block's `tick`: places or drops its block in its own tick,
|
||||
/// so a block falling onto one that just landed lands on top of it. `i` is
|
||||
@@ -14,6 +74,25 @@ impl Game<'_> {
|
||||
/// shifted every block after it twice, for every falling block, every tick.
|
||||
/// One that lands, drops or goes leaves the list before its block is placed
|
||||
/// or dropped, as before; on an error the list keeps it as it was.
|
||||
/// The end of a piston's `Entity.move(MoverType.PISTON, …)` for falling
|
||||
/// block `i` after a step of `moved`: `checkFallDamage` adds the drop and,
|
||||
/// on the ground, runs fallOn and posts HIT_GROUND after a fall, then
|
||||
/// forgets it, so its own next tick does not land again.
|
||||
pub(super) fn finish_falling_piston_move(&mut self, i: usize, moved: Vec3) {
|
||||
let mut f = self.falling[i].clone();
|
||||
if moved.y < 0.0 {
|
||||
f.fall_distance -= f64::from(moved.y as f32);
|
||||
}
|
||||
if f.on_ground {
|
||||
self.falling_block_impact(&mut f);
|
||||
if f.fall_distance > 0.0 {
|
||||
let bounds = horizon_client::falling_block::bounds(f.position);
|
||||
self.post_landing_event(f.position, bounds, false);
|
||||
}
|
||||
f.fall_distance = 0.0;
|
||||
}
|
||||
self.falling[i] = f;
|
||||
}
|
||||
pub(super) fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
|
||||
let entity_drops = self.ui.rules.bool("entity_drops");
|
||||
let join = self.falling[i].join;
|
||||
@@ -134,7 +213,12 @@ impl Game<'_> {
|
||||
f.velocity.z = 0.0;
|
||||
}
|
||||
if on_ground {
|
||||
// fallOn (causeFallDamage), then HIT_GROUND after a fall.
|
||||
self.falling_block_impact(f);
|
||||
if f.fall_distance > 0.0 {
|
||||
let bounds = horizon_client::falling_block::bounds(f.position);
|
||||
self.post_landing_event(f.position, bounds, false);
|
||||
}
|
||||
f.fall_distance = 0.0;
|
||||
}
|
||||
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
|
||||
@@ -233,3 +317,98 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod geometry_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn falling_model_cache_matches_direct_vertices() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
Instant::now(),
|
||||
false,
|
||||
false,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
for frame in 0..2 {
|
||||
for (name, pos, origin, light) in [
|
||||
(
|
||||
"minecraft:sand",
|
||||
BlockPos::new(1, 80, 3),
|
||||
glam::Vec3::new(-3.25, 12.5, 0.0),
|
||||
0x7010,
|
||||
),
|
||||
(
|
||||
"minecraft:sand",
|
||||
BlockPos::new(9, 90, -4),
|
||||
glam::Vec3::new(8.125, -0.0, -9.5),
|
||||
0xf020,
|
||||
),
|
||||
(
|
||||
"minecraft:gravel",
|
||||
BlockPos::new(-2, 70, 5),
|
||||
glam::Vec3::new(-0.0, -17.75, 2.5),
|
||||
0x5080,
|
||||
),
|
||||
] {
|
||||
let state = game.pack.assets.registry.default_state(name).unwrap();
|
||||
let seed = horizon_core::random::position_seed(pos);
|
||||
let mut expected = Vec::new();
|
||||
game.block_model_vertices(
|
||||
state,
|
||||
seed,
|
||||
glam::Mat4::from_translation(origin),
|
||||
light,
|
||||
false,
|
||||
&mut expected,
|
||||
);
|
||||
let mut actual = Vec::new();
|
||||
game.falling_model_vertices(state, pos, origin, light, &mut actual);
|
||||
let (_, tint) = game.block_face_sprite(state).unwrap();
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
for vertices in [&mut expected, &mut actual] {
|
||||
for v in vertices {
|
||||
v.color = horizon_client::hud_layout::multiply(v.color, tint);
|
||||
}
|
||||
}
|
||||
assert!(!expected.is_empty());
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (a, b) in actual.iter().zip(&expected) {
|
||||
assert_eq!(
|
||||
a.pos.map(f32::to_bits),
|
||||
b.pos.map(f32::to_bits),
|
||||
"frame {frame}"
|
||||
);
|
||||
assert_eq!(a.uv.map(f32::to_bits), b.uv.map(f32::to_bits));
|
||||
assert_eq!(a.glint_uv.map(f32::to_bits), b.glint_uv.map(f32::to_bits));
|
||||
assert_eq!(
|
||||
(a.color, a.light, a.normal, a.layer),
|
||||
(b.color, b.light, b.normal, b.layer)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,8 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
// `ItemStack.useOn` awards ITEM_USED for `FireworkRocketItem.useOn`'s SUCCESS.
|
||||
self.award_stat("used", "minecraft:firework_rocket", 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
@@ -571,6 +573,8 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
|
||||
}
|
||||
// `this.gameEvent(GameEvent.EXPLODE, this.getOwner())`, before the blast.
|
||||
self.rocket_explode_event(r.owner, r.guest, at);
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
let from = r.position;
|
||||
@@ -648,9 +652,34 @@ impl Game<'_> {
|
||||
let camera = self.eye_position().unwrap_or(at);
|
||||
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
.fireworks(at, v, r.explosions.clone(), camera, !r.silent);
|
||||
self.native_firework_explode(r.id, at, r.explosions.len());
|
||||
}
|
||||
/// `FireworkRocketEntity.explode`'s game event: EXPLODE at the rocket, from
|
||||
/// its owner (`Projectile.getOwner`), a guest's rocket as theirs.
|
||||
fn rocket_explode_event(&mut self, mut owner: Option<Who>, guest: Option<u32>, at: [f64; 3]) {
|
||||
if let Some(conn) = guest.filter(|conn| self.lan_acting != Some(*conn)) {
|
||||
let posted = if let Some(mut host) = self.lan.take() {
|
||||
let posted = self
|
||||
.as_guest(&mut host, conn, |g| {
|
||||
let ctx = g.who_context(owner, None);
|
||||
g.post_game_event("minecraft:explode", at, ctx);
|
||||
})
|
||||
.is_some();
|
||||
self.lan = Some(host);
|
||||
posted
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if posted {
|
||||
return;
|
||||
}
|
||||
// An owner who left cannot resolve to the host: no source entity.
|
||||
owner = None;
|
||||
}
|
||||
let ctx = self.who_context(owner, None);
|
||||
self.post_game_event("minecraft:explode", at, ctx);
|
||||
}
|
||||
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
|
||||
let _attacker = self.knockback_scope(if attacker == Some(Who::Player) {
|
||||
self.player.as_ref().map_or(0, |p| p.entity_id)
|
||||
@@ -751,7 +780,7 @@ impl Game<'_> {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
|
||||
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at), !r.silent);
|
||||
}
|
||||
}
|
||||
/// `dealExplosionDamage` on a player that is not the rocket's
|
||||
@@ -861,6 +890,10 @@ fn use_on_passes(fall_flying: bool) -> bool {
|
||||
fall_flying
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fireworks_events_tests.rs"]
|
||||
mod events_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A rocket draws its UUID's two longs first, then the motion's two triangles
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! `FireworkRocketEntity.explode`'s game event and sound gate, and the
|
||||
//! `ITEM_USED` an `useOn` counts. Prepared resources only.
|
||||
use super::Game;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
particles::{FireworkExplosion, FireworkShape, ParticleLevel},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap(),
|
||||
);
|
||||
game.world.random = LegacyRandom::new(114);
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
/// One `small_ball` that twinkles, as `/summon` reads it: a `Starter` of 16
|
||||
/// ticks that plays a blast first and a twinkle last.
|
||||
fn twinkling() -> FireworkExplosion {
|
||||
FireworkExplosion {
|
||||
shape: FireworkShape::SmallBall,
|
||||
colors: vec![0xFF5555],
|
||||
fade: Vec::new(),
|
||||
trail: true,
|
||||
twinkle: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_particles(game: &mut Game<'_>) {
|
||||
let level = ParticleLevel {
|
||||
world: &game.world,
|
||||
facts: game.res.facts(),
|
||||
collision: game.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
game.particles.tick(&level);
|
||||
}
|
||||
|
||||
/// `FireworkParticles.Starter.playSound`: a silent rocket launches quietly (as
|
||||
/// it already did) and bursts quietly too, drawing its sparks and flash only.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_silent_rocket_bursts_without_blast_or_twinkle() {
|
||||
with_game(|game| {
|
||||
let stack = ItemStack::new("minecraft:firework_rocket", 1);
|
||||
let mut launching = game.new_rocket(
|
||||
Vec3::new(6.5, 201.5, 10.5),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
launching.silent = true;
|
||||
launching.lifetime = 100;
|
||||
game.rockets.push(launching);
|
||||
game.audio.capture = Some(Vec::new());
|
||||
game.tick_rockets();
|
||||
let heard = game.audio.capture.take().unwrap_or_default();
|
||||
assert!(
|
||||
heard
|
||||
.iter()
|
||||
.all(|s| s.event != "minecraft:entity.firework_rocket.launch"),
|
||||
"a silent rocket launches quietly: {heard:?}"
|
||||
);
|
||||
game.rockets.clear();
|
||||
|
||||
let (quiet_at, loud_at) = ([6.5, 201.5, 10.5], [12.5, 201.5, 10.5]);
|
||||
let mut quiet = game.new_rocket(
|
||||
Vec3::new(quiet_at[0], quiet_at[1], quiet_at[2]),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
quiet.silent = true;
|
||||
quiet.explosions = vec![twinkling()];
|
||||
let mut loud = game.new_rocket(
|
||||
Vec3::new(loud_at[0], loud_at[1], loud_at[2]),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
loud.explosions = vec![twinkling()];
|
||||
game.explode_rocket(&quiet);
|
||||
game.explode_rocket(&loud);
|
||||
game.particles.sounds.clear();
|
||||
for _ in 0..20 {
|
||||
tick_particles(game);
|
||||
}
|
||||
let bursts: Vec<(&str, [f64; 3])> = game
|
||||
.particles
|
||||
.sounds
|
||||
.iter()
|
||||
.filter(|s| s.0.starts_with("minecraft:entity.firework_rocket."))
|
||||
.map(|s| (s.0, s.1))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
bursts,
|
||||
vec![
|
||||
("minecraft:entity.firework_rocket.blast", loud_at),
|
||||
("minecraft:entity.firework_rocket.twinkle", loud_at),
|
||||
],
|
||||
"only the loud rocket bangs and twinkles, both at its own position"
|
||||
);
|
||||
assert!(
|
||||
!game.particles.is_empty(),
|
||||
"the silent rocket still drew its sparks and flash"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Recording of `Game::post_game_event`, as the sculk tests read it.
|
||||
fn start_events() {
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
|
||||
}
|
||||
|
||||
fn take_explosions() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| {
|
||||
std::mem::take(e.borrow_mut().as_mut().unwrap())
|
||||
.into_iter()
|
||||
.filter(|e| e.0 == "minecraft:explode")
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn stop_events() {
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = None);
|
||||
}
|
||||
|
||||
/// `FireworkRocketEntity.explode`: `gameEvent(GameEvent.EXPLODE, getOwner())` at
|
||||
/// the rocket, once, with and without explosions; a rocket nobody owns (a
|
||||
/// dispenser's) has no source entity.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_detonating_rocket_posts_one_explode_from_its_owner() {
|
||||
with_game(|game| {
|
||||
let stack = ItemStack::new("minecraft:firework_rocket", 1);
|
||||
let mut burst = game.new_rocket(
|
||||
Vec3::new(8.5, 202.0, 8.5),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
burst.explosions = vec![twinkling()];
|
||||
burst.owner = Some(Who::Player);
|
||||
let mut plain = game.new_rocket(
|
||||
Vec3::new(9.5, 202.0, 8.5),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
plain.owner = Some(Who::Player);
|
||||
let mut stray = game.new_rocket(
|
||||
Vec3::new(10.5, 202.0, 8.5),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
stray.explosions = vec![twinkling()];
|
||||
|
||||
start_events();
|
||||
game.explode_rocket(&burst);
|
||||
assert_eq!(
|
||||
take_explosions(),
|
||||
vec![("minecraft:explode", [8.5, 202.0, 8.5], Some(Who::Player))],
|
||||
"the burst is heard once, at the rocket, from the player who shot it"
|
||||
);
|
||||
game.explode_rocket(&plain);
|
||||
assert_eq!(
|
||||
take_explosions(),
|
||||
vec![("minecraft:explode", [9.5, 202.0, 8.5], Some(Who::Player))],
|
||||
"a rocket with no explosions still posts it"
|
||||
);
|
||||
game.explode_rocket(&stray);
|
||||
assert_eq!(
|
||||
take_explosions(),
|
||||
vec![("minecraft:explode", [10.5, 202.0, 8.5], None)],
|
||||
"`getOwner()` is null: no source entity"
|
||||
);
|
||||
stop_events();
|
||||
});
|
||||
}
|
||||
|
||||
/// `ItemStack.useOn` awards `ITEM_USED` for `FireworkRocketItem.useOn`'s
|
||||
/// SUCCESS; `FireworkRocketItem.use` while gliding awards its own.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn launching_from_a_block_and_while_gliding_use_the_rocket_up() {
|
||||
with_game(|game| {
|
||||
game.creative = false;
|
||||
let stack = ItemStack::new("minecraft:firework_rocket", 1);
|
||||
let hit = super::Hit {
|
||||
pos: [2, 199, 2],
|
||||
face: [0, 1, 0],
|
||||
point: [2.5, 200.0, 2.5],
|
||||
};
|
||||
for used in 1..=5 {
|
||||
game.inventory[game.selected] = Some(stack.clone());
|
||||
assert!(game.launch_firework(&hit, &stack), "a rocket goes up");
|
||||
assert_eq!(
|
||||
game.stats.get("used", "minecraft:firework_rocket"),
|
||||
used,
|
||||
"each right-click on the block counts one use"
|
||||
);
|
||||
}
|
||||
game.player.as_mut().unwrap().fall_flying = true;
|
||||
game.inventory[game.selected] = Some(stack.clone());
|
||||
assert!(game.boost_firework(&stack), "the gliding boost launches");
|
||||
assert_eq!(game.stats.get("used", "minecraft:firework_rocket"), 6);
|
||||
});
|
||||
}
|
||||
@@ -297,23 +297,23 @@ impl Game<'_> {
|
||||
action = "put";
|
||||
self.audio
|
||||
.play(&sound(&self.item_frames[i], "add_item"), Some(at), 1.0, 1.0);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
} else {
|
||||
let s = sound(&self.item_frames[i], "rotate_item");
|
||||
self.audio.play(&s, Some(at), 1.0, 1.0);
|
||||
let f = &mut self.item_frames[i];
|
||||
f.rotation = (f.rotation + 1) % 8;
|
||||
action = "rotate";
|
||||
let s = sound(f, "rotate_item");
|
||||
self.audio.play(&s, Some(at), 1.0, 1.0);
|
||||
}
|
||||
let p = self.item_frames[i].pos;
|
||||
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
|
||||
self.frame_output_changed(p);
|
||||
// ItemFrame.interact: BLOCK_CHANGE by the player, after setItem or
|
||||
// setRotation.
|
||||
// ItemFrame.interact: BLOCK_CHANGE by the player after setItem or
|
||||
// setRotation, then the held item is consumed.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event("minecraft:block_change", at, ctx);
|
||||
if action == "put" && !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
let frame = self.item_frames.iter().find(|f| f.id == entity_id);
|
||||
let item = frame
|
||||
@@ -362,18 +362,16 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.item_frames[i].clone()
|
||||
};
|
||||
self.audio.play(
|
||||
&sound(&f, if with_frame { "break" } else { "remove_item" }),
|
||||
Some(at),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
if !with_frame {
|
||||
self.item_frames[i].item = None;
|
||||
} else {
|
||||
// BlockAttachedEntity.kill, before dropItem. A frame holding an
|
||||
// item only gives the item up (ItemFrame.hurtServer).
|
||||
// BlockAttachedEntity.kill precedes ItemFrame.dropItem's break sound.
|
||||
self.entity_killed_event(at, true);
|
||||
self.audio.play(&sound(&f, "break"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
// ItemFrame.dropItem clears the item (updating comparators) before drops.
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
self.drop_frame_items(&mut f, with_frame, true);
|
||||
if !with_frame {
|
||||
@@ -383,10 +381,12 @@ impl Game<'_> {
|
||||
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event("minecraft:block_change", at, ctx);
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
if !with_frame {
|
||||
// ItemFrame.hurtServer plays this after the event.
|
||||
self.audio
|
||||
.play(&sound(&f, "remove_item"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_item_frame_change(
|
||||
player,
|
||||
@@ -519,16 +519,16 @@ impl Game<'_> {
|
||||
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
|
||||
{
|
||||
self.item_frames.remove(i);
|
||||
self.entity_killed_event(center(&f), false);
|
||||
// BlockAttachedEntity.tick discards without a death event.
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
// ItemFrame.dropItem(level, null): BLOCK_CHANGE after the drops.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
// ItemFrame.dropItem(level, null): sound, drops, then event.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -840,3 +840,121 @@ mod map_limit_tests {
|
||||
assert_eq!(framed.and_then(|s| s.data()?.map_id), Some(8));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod game_event_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::mobs::Who;
|
||||
|
||||
fn frame(id: u32, pos: [i32; 3], item: Option<ItemStack>) -> ItemFrame {
|
||||
ItemFrame {
|
||||
id,
|
||||
pos,
|
||||
facing: 3,
|
||||
glow: false,
|
||||
item,
|
||||
rotation: 0,
|
||||
drop_chance: 1.0,
|
||||
fixed: false,
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name: None,
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
}
|
||||
}
|
||||
fn at(id: u32) -> Option<InteractTarget> {
|
||||
Some(InteractTarget {
|
||||
id: id as i32,
|
||||
location: [0.0; 3],
|
||||
})
|
||||
}
|
||||
/// The events posted since [`reset`].
|
||||
fn posted() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|e| e.borrow().clone().unwrap_or_default())
|
||||
}
|
||||
fn reset() {
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
|
||||
}
|
||||
|
||||
/// `ItemFrame.interact`: putting an item in and turning it each post
|
||||
/// `GameEvent.BLOCK_CHANGE` from the player, at the frame's position.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn putting_and_rotating_a_frame_item_posts_block_change() {
|
||||
let mut game = server_game();
|
||||
game.item_frames = vec![frame(1, [0, 64, 1], None)];
|
||||
let center = [0.5, 64.5, 1.03125];
|
||||
*game.held_mut() = Some(ItemStack::new("minecraft:stick", 3));
|
||||
|
||||
let expected = [("minecraft:block_change", center, Some(Who::Player))];
|
||||
reset();
|
||||
assert!(game.interact_frame(at(1)));
|
||||
assert_eq!(posted(), expected);
|
||||
assert_eq!(game.held().map(|s| s.count), Some(2), "one goes in");
|
||||
assert_eq!(game.item_frames[0].item.as_ref().map(|s| s.count), Some(1));
|
||||
|
||||
reset();
|
||||
assert!(game.interact_frame(at(1)));
|
||||
assert_eq!(posted(), expected);
|
||||
assert_eq!(game.item_frames[0].rotation, 1);
|
||||
}
|
||||
|
||||
/// `ItemFrame.hurtServer`: the item coming out posts `BLOCK_CHANGE` from
|
||||
/// the player hitting the frame.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn taking_a_frame_item_out_posts_block_change_from_the_player() {
|
||||
let mut game = server_game();
|
||||
game.item_frames = vec![frame(
|
||||
1,
|
||||
[0, 64, 1],
|
||||
Some(ItemStack::new("minecraft:stick", 1)),
|
||||
)];
|
||||
let center = [0.5, 64.5, 1.03125];
|
||||
// A player looking straight at the frame's centre from two blocks away
|
||||
// (eye height 1.62 above the feet).
|
||||
let feet = Vec3::new(0.5, 64.5 - 1.62, -1.0);
|
||||
game.player =
|
||||
Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).expect("a player"));
|
||||
game.yaw = 0.0;
|
||||
game.pitch = 0.0;
|
||||
|
||||
let expected = [("minecraft:block_change", center, Some(Who::Player))];
|
||||
reset();
|
||||
assert!(game.attack_frame(), "the frame is in reach");
|
||||
assert!(game.item_frames[0].item.is_none());
|
||||
assert_eq!(posted(), expected);
|
||||
|
||||
// The empty frame itself then breaks (`BlockAttachedEntity.hurtServer`
|
||||
// then `ItemFrame.dropItem`), with the same event.
|
||||
reset();
|
||||
assert!(game.attack_frame());
|
||||
assert!(game.item_frames.is_empty());
|
||||
assert_eq!(
|
||||
posted(),
|
||||
[
|
||||
("minecraft:entity_die", center, Some(Who::Player)),
|
||||
("minecraft:block_change", center, Some(Who::Player))
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `ItemFrame.dropItem` from `BlockAttachedEntity.tick`, when a frame
|
||||
/// loses the block it hangs on: the event has no source.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_frame_that_falls_posts_block_change_without_source() {
|
||||
let mut game = server_game();
|
||||
let mut f = frame(1, [0, 64, 1], None);
|
||||
// BlockAttachedEntity.tick checks every 100 ticks.
|
||||
f.check_interval = 99;
|
||||
game.item_frames = vec![f];
|
||||
let center = [0.5, 64.5, 1.03125];
|
||||
|
||||
reset();
|
||||
game.tick_item_frames();
|
||||
assert!(game.item_frames.is_empty(), "nothing holds the frame");
|
||||
assert_eq!(posted(), [("minecraft:block_change", center, None)]);
|
||||
}
|
||||
}
|
||||
@@ -253,11 +253,17 @@ impl Game<'_> {
|
||||
registries.function_tags = store.function_tags.keys().cloned().collect();
|
||||
registries.predicates = store.predicates.keys().cloned().collect();
|
||||
registries.item_modifiers = store.item_modifiers.keys().cloned().collect();
|
||||
registries
|
||||
let sources = registries
|
||||
.by_registry
|
||||
.entry("minecraft:slot_source".into())
|
||||
.or_default()
|
||||
.ids = store.slot_sources.keys().cloned().collect();
|
||||
.or_default();
|
||||
sources.ids = store.slot_sources.keys().cloned().collect();
|
||||
sources.tags = store
|
||||
.slot_source_tags
|
||||
.keys()
|
||||
.filter(|id| store.slot_source_tag(id).is_ok())
|
||||
.cloned()
|
||||
.collect();
|
||||
for (id, ids) in [
|
||||
("minecraft:predicate", ®istries.predicates),
|
||||
("minecraft:item_modifier", ®istries.item_modifiers),
|
||||
@@ -311,10 +317,19 @@ impl Game<'_> {
|
||||
reg.function_tags = self.fns.store.function_tags.keys().cloned().collect();
|
||||
reg.predicates = self.fns.store.predicates.keys().cloned().collect();
|
||||
reg.item_modifiers = self.fns.store.item_modifiers.keys().cloned().collect();
|
||||
reg.by_registry
|
||||
let sources = reg
|
||||
.by_registry
|
||||
.entry("minecraft:slot_source".into())
|
||||
.or_default()
|
||||
.ids = self.fns.store.slot_sources.keys().cloned().collect();
|
||||
.or_default();
|
||||
sources.ids = self.fns.store.slot_sources.keys().cloned().collect();
|
||||
sources.tags = self
|
||||
.fns
|
||||
.store
|
||||
.slot_source_tags
|
||||
.keys()
|
||||
.filter(|id| self.fns.store.slot_source_tag(id).is_ok())
|
||||
.cloned()
|
||||
.collect();
|
||||
reg.schedules = self.fns.schedule.iter().map(|s| s.id.clone()).collect();
|
||||
reg.datapacks_enabled = std::iter::once("vanilla".to_owned())
|
||||
.chain(self.fns.enabled.iter().cloned())
|
||||
|
||||
@@ -740,18 +740,24 @@ impl Game<'_> {
|
||||
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
|
||||
/// the attribute's range.
|
||||
pub(super) fn main_hand_attack_speed(&self) -> f64 {
|
||||
// The base comes from the player's attribute map so `/attribute ... base set` applies.
|
||||
if let Some(player) = &self.player
|
||||
&& let Ok(speed) = player
|
||||
.attributes
|
||||
.computed_value_of("minecraft:attack_speed")
|
||||
&& (self.remote.is_some()
|
||||
|| !self.applied_modifiers.is_empty()
|
||||
|| !self.potions.applied.is_empty())
|
||||
{
|
||||
return speed.clamp(0.0, 1024.0);
|
||||
}
|
||||
let base = self
|
||||
.player
|
||||
.as_ref()
|
||||
.and_then(|player| {
|
||||
let key = player.attributes.key("minecraft:attack_speed")?;
|
||||
let handle = player.attributes.lookup(Some(&key))?;
|
||||
player
|
||||
.attributes
|
||||
.instance(&handle)
|
||||
.computed_value_of("minecraft:attack_speed")
|
||||
.ok()
|
||||
.map(|value| value.base_value())
|
||||
})
|
||||
.unwrap_or(horizon_client::attack_strength::BASE_ATTACK_SPEED);
|
||||
let value = base
|
||||
|
||||
@@ -21,6 +21,27 @@ pub(super) struct ItemAreas {
|
||||
}
|
||||
|
||||
impl ItemAreas {
|
||||
/// Build the grid for villager item sensors only when many due sensors
|
||||
/// would otherwise repeatedly scan a large, spatially spread item list.
|
||||
/// Sampling a few 32-block columns avoids overhead for crowded piles.
|
||||
pub(super) fn worthwhile(items: &[ItemEntity], queries: usize) -> bool {
|
||||
if items.len() < 2048 || queries < 32 {
|
||||
return false;
|
||||
}
|
||||
let stride = (items.len() / 32).max(1);
|
||||
let mut columns = std::collections::HashSet::new();
|
||||
for item in items.iter().step_by(stride) {
|
||||
let p = item.position;
|
||||
if p.is_finite() {
|
||||
columns.insert((cell(p.x) >> 2, cell(p.z) >> 2));
|
||||
if columns.len() >= 9 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether an item of `items` may meet `area`: false only when none can.
|
||||
pub(super) fn may_meet(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> bool {
|
||||
self.build(items, next_entity);
|
||||
@@ -308,6 +329,57 @@ mod tests {
|
||||
assert_eq!(scan.next_from(2, items.len()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn villager_sensing_candidates_match_full_scan_order() {
|
||||
let items: Vec<_> = (0..2600)
|
||||
.map(|i| {
|
||||
let x = f64::from(i % 52) * 31.0 - 800.0;
|
||||
let z = f64::from(i / 52) * 31.0 - 750.0;
|
||||
item_at(x, 64.0 + f64::from(i % 3) * 0.25, z)
|
||||
})
|
||||
.collect();
|
||||
assert!(ItemAreas::worthwhile(&items, 32));
|
||||
assert!(!ItemAreas::worthwhile(&items, 31));
|
||||
|
||||
let clustered: Vec<_> = (0..2048).map(|_| item_at(1.0, 64.0, 1.0)).collect();
|
||||
assert!(!ItemAreas::worthwhile(&clustered, 32));
|
||||
|
||||
let mut cells = ItemAreas::default();
|
||||
for (x, z) in [
|
||||
(-800.0, -750.0),
|
||||
(-80.0, 40.0),
|
||||
(0.0, 0.0),
|
||||
(220.0, -130.0),
|
||||
(790.0, 770.0),
|
||||
] {
|
||||
// A villager's inflated item sensor box, with a conservative
|
||||
// fraction for the item bounding box at cell boundaries.
|
||||
let area = Aabb::new(
|
||||
Vec3::new(x - 32.4, 48.0, z - 32.4),
|
||||
Vec3::new(x + 32.4, 80.0, z + 32.4),
|
||||
);
|
||||
let expected: Vec<usize> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, e)| area.intersects(e.bounds()).then_some(i))
|
||||
.collect();
|
||||
let mut scan = cells.scan(&items, 1, area);
|
||||
let mut actual = Vec::new();
|
||||
let mut j = 0;
|
||||
loop {
|
||||
j = scan.next_from(j, items.len());
|
||||
let Some(e) = items.get(j) else {
|
||||
break;
|
||||
};
|
||||
if area.intersects(e.bounds()) {
|
||||
actual.push(j);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
assert_eq!(actual, expected, "sensor at {x}, {z}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_spawned_after_the_cells_were_built_is_seen() {
|
||||
let mut items = vec![item_at(100.5, 64.0, 100.5)];
|
||||
|
||||
@@ -133,4 +133,44 @@ fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entit
|
||||
game.tick_items();
|
||||
assert!(game.dropped.is_empty(), "consumed stack of {count}");
|
||||
}
|
||||
|
||||
// More than the helper threshold, with a native contact hook loaded:
|
||||
// every item must still reach its turn's hook, keeping opaque components.
|
||||
// Unique components prevent merges despite all items sharing a landing.
|
||||
for count in [13, 2, 1] {
|
||||
game.dropped.clear();
|
||||
probe.contacts.lock().unwrap().clear();
|
||||
for n in 0..300 {
|
||||
let mut stack = input.clone();
|
||||
stack.count = count;
|
||||
stack
|
||||
.data_mut()
|
||||
.set_other("fixture:opaque", Some(horizon_world::nbt::Tag::Long(n)));
|
||||
game.spawn_item(stack, [landing[0], 64.1, landing[2]], [0.0, -0.3, 0.0], 20);
|
||||
game.dropped.last_mut().unwrap().ticks = 39;
|
||||
}
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
let before = game.dropped.clone();
|
||||
game.tick_items();
|
||||
let expected_contacts: Vec<_> = before
|
||||
.iter()
|
||||
.map(|item| (false, item.id, [4, 63, 4], landing, count))
|
||||
.collect();
|
||||
assert_eq!(*probe.contacts.lock().unwrap(), expected_contacts);
|
||||
if count <= 2 {
|
||||
assert!(game.dropped.is_empty(), "consumed 300 stacks of {count}");
|
||||
} else {
|
||||
assert_eq!(game.dropped.len(), 300);
|
||||
for (item, old) in game.dropped.iter().zip(&before) {
|
||||
let mut expected = old.stack.clone();
|
||||
expected.count = 11;
|
||||
assert_eq!(item.stack, expected);
|
||||
assert_eq!(item.id, old.id);
|
||||
assert_eq!(item.join_seq(), old.join_seq());
|
||||
assert_eq!(item.position, vanilla.position);
|
||||
assert_eq!(item.velocity, vanilla.velocity);
|
||||
assert_eq!(item.age, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn count_equality_uses_live_stack_bounds_and_command_flow() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0 15 15"));
|
||||
assert!(game.world.loaded(4, 4), "actual loaded world chunk");
|
||||
assert!(game.run_command("setblock 4 199 4 stone"));
|
||||
assert!(game.run_command("setblock 4 200 4 chest{Items:[{Slot:0b,id:\"minecraft:diamond\",count:1},{Slot:1b,id:\"minecraft:diamond\",count:3},{Slot:2b,id:\"minecraft:diamond\",count:4},{Slot:3b,id:\"minecraft:dirt\",count:3},{Slot:5b,id:\"minecraft:stone\",count:2,components:{\"minecraft:custom_data\":{h194:1}}}]}"));
|
||||
assert!(game.run_command("scoreboard objectives add h194 dummy"));
|
||||
let clock = game.world.game_time;
|
||||
let counts = |g: &Game<'_>| {
|
||||
let horizon_client::containers::BlockEntity::Chest(slots) =
|
||||
g.world.block_entities.get(&(4, 200, 4)).unwrap()
|
||||
else {
|
||||
panic!("actual chest model missing")
|
||||
};
|
||||
assert!(slots[4].is_none(), "actual empty slot");
|
||||
[
|
||||
slots[0].as_ref().unwrap().count,
|
||||
slots[1].as_ref().unwrap().count,
|
||||
slots[2].as_ref().unwrap().count,
|
||||
]
|
||||
};
|
||||
assert_eq!(counts(&game), [1, 3, 4]);
|
||||
let mut failed = Vec::new();
|
||||
for (slot, count) in [1, 3, 4].into_iter().enumerate() {
|
||||
assert!(
|
||||
game.run_command(&format!(
|
||||
"execute if items block 4 200 4 container.{slot} diamond"
|
||||
)),
|
||||
"plain admitted command path"
|
||||
);
|
||||
let stack = ItemStack::new("minecraft:diamond", count);
|
||||
assert!(
|
||||
horizon_client::item_data::encode_components(&stack)
|
||||
.is_none_or(|t| t.get("minecraft:count").is_none()),
|
||||
"count is not a component patch"
|
||||
);
|
||||
for (term, expected) in [
|
||||
("count=3", count == 3),
|
||||
("count=4", count == 4),
|
||||
("!count=3", count != 3),
|
||||
("count={min:2}", count >= 2),
|
||||
("count~3", count == 3),
|
||||
("minecraft:count={min:2,max:3}", (2..=3).contains(&count)),
|
||||
] {
|
||||
let predicate = ItemInput {
|
||||
id: "minecraft:diamond".into(),
|
||||
tag: false,
|
||||
any: false,
|
||||
components: Some(format!("[{term}]")),
|
||||
};
|
||||
let actual = game.stack_matches(&predicate, &stack);
|
||||
if actual != expected {
|
||||
failed.push(format!(
|
||||
"direct count{count} {term}: {actual}, expected {expected}"
|
||||
));
|
||||
}
|
||||
assert_eq!(stack.count, count, "read-only evaluator");
|
||||
assert!(game.run_command("scoreboard players set #r h194 91"));
|
||||
assert!(game.run_command("scoreboard players set #s h194 17"));
|
||||
let accepted = game.run_command(&format!("execute store result score #r h194 store success score #s h194 if items block 4 200 4 container.{slot} diamond[{term}]"));
|
||||
let score = |name| game.score.board.score(name, "h194").unwrap().value;
|
||||
let got = (accepted, score("#r"), score("#s"));
|
||||
let want = if expected {
|
||||
(true, count as i32, 1)
|
||||
} else {
|
||||
(false, 0, 0)
|
||||
};
|
||||
if got != want {
|
||||
failed.push(format!(
|
||||
"command slot{slot} {term}: {got:?}, expected {want:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(game.run_command("execute unless items block 4 200 4 container.3 diamond[count=3]"));
|
||||
assert!(game.run_command("execute unless items block 4 200 4 container.4 diamond[count=3]"));
|
||||
assert!(
|
||||
game.run_command("execute if items block 4 200 4 container.5 stone[custom_data={h194:1}]")
|
||||
);
|
||||
assert_eq!(counts(&game), [1, 3, 4], "no inventory mutation");
|
||||
assert_eq!(game.world.game_time, clock, "zero actor ticks");
|
||||
assert!(
|
||||
failed.is_empty(),
|
||||
"actual count predicate mismatches: {failed:?}"
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
//! `tick_experience`), the stacks a destroyed container lets fall, and spawning and merging orbs.
|
||||
use super::*;
|
||||
|
||||
mod ahead;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_items(&mut self) {
|
||||
// EntityTickEvent.Pre for the items about to tick, and the Post after them.
|
||||
@@ -43,6 +45,13 @@ impl Game<'_> {
|
||||
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
let mut nearby = Vec::new();
|
||||
let mut bubble_tops = Vec::new();
|
||||
let mut moves = ahead::Moves::work_out(self, &items, &obstacles, round, |item| {
|
||||
!self.joined_during_entity_pass(item.id)
|
||||
&& ticking(
|
||||
item.position.x.floor() as i32,
|
||||
item.position.z.floor() as i32,
|
||||
)
|
||||
});
|
||||
// `ItemUtils.onContainerDestroyed`: what a destroyed item held, and
|
||||
// where it lay, for adding to the level once its own tick is done.
|
||||
let mut destroyed: Vec<(ItemStack, Vec3)> = Vec::new();
|
||||
@@ -65,33 +74,35 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
|
||||
// where the item can reach this tick (a block of slack for the next velocity).
|
||||
let reach = Vec3::new(
|
||||
item.velocity.x.abs() + 1.0,
|
||||
item.velocity.y.abs() + 1.0,
|
||||
item.velocity.z.abs() + 1.0,
|
||||
);
|
||||
obstacles.near(item.bounds().inflate(reach), &mut nearby);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
|
||||
// or the border, placed or closed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging
|
||||
&& item.inside_block(
|
||||
&self.world,
|
||||
self.collision,
|
||||
let mut at_turn = |item: &mut ItemEntity| {
|
||||
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
|
||||
// where the item can reach this tick.
|
||||
obstacles.near(ahead::reach(item), &mut nearby);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
|
||||
// or the border, placed or closed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging
|
||||
&& item.inside_block(
|
||||
&self.world,
|
||||
self.collision,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut entered = Vec::new();
|
||||
let (alive, discarded) = self.tick_item_with_block_contacts(
|
||||
item,
|
||||
escape_speed,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
false,
|
||||
&mut |splash| entered.push(splash),
|
||||
);
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut entered = Vec::new();
|
||||
let (alive, discarded) = self.tick_item_with_block_contacts(
|
||||
item,
|
||||
escape_speed,
|
||||
&nearby,
|
||||
false,
|
||||
&mut |splash| entered.push(splash),
|
||||
);
|
||||
(no_physics, alive, discarded, entered)
|
||||
};
|
||||
let (no_physics, alive, discarded, entered) = match moves.take(i, item, &mut at_turn) {
|
||||
Some(moved) => moved,
|
||||
None => at_turn(item),
|
||||
};
|
||||
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
|
||||
// to clients). Its addParticle is empty.
|
||||
for splash in entered {
|
||||
@@ -309,17 +320,13 @@ impl Game<'_> {
|
||||
self.spawn_destroyed_contents(destroyed);
|
||||
}
|
||||
/// `ItemUtils.onContainerDestroyed`: each stack a destroyed item held
|
||||
/// becomes an item entity where that item lay (`new ItemEntity(level, x,
|
||||
/// y, z, stack)`), nudged by 0.2 less 0.1 per axis and pickable at once.
|
||||
/// becomes an item entity where that item lay, made by
|
||||
/// `new ItemEntity(level, x, y, z, stack)`: its motion is that
|
||||
/// constructor's own, 0.2 up and ±0.1 sideways from the item's random,
|
||||
/// and it is pickable at once.
|
||||
pub(super) fn spawn_destroyed_contents(&mut self, destroyed: Vec<(ItemStack, Vec3)>) {
|
||||
for (stack, at) in destroyed {
|
||||
let r = &mut self.world.random;
|
||||
let motion = [
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, [at.x, at.y, at.z], motion, 0);
|
||||
self.spawn_item_own(stack, [at.x, at.y, at.z], 0);
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
|
||||
@@ -671,27 +678,55 @@ impl Game<'_> {
|
||||
self.add_item_entity(stack, position, None, pickup_delay)
|
||||
}
|
||||
|
||||
/// `DefaultDispenseItemBehavior.spawnItem`: `new ItemEntity(level, x, y, z,
|
||||
/// stack)` draws its default motion from the item's own random, then
|
||||
/// `setDeltaMovement` throws it with `velocity` before `addFreshEntity`.
|
||||
pub(super) fn spawn_item_thrown(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
position: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
) {
|
||||
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0);
|
||||
}
|
||||
|
||||
pub(super) fn add_item_entity(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
position: [f64; 3],
|
||||
velocity: Option<[f64; 3]>,
|
||||
pickup_delay: u32,
|
||||
) -> [f64; 3] {
|
||||
self.add_item_entity_drawn(stack, position, velocity, velocity.is_none(), pickup_delay)
|
||||
}
|
||||
|
||||
/// `add_item_entity`; `constructor_motion` draws the five-argument
|
||||
/// constructor's default motion, which a given `velocity` then replaces.
|
||||
fn add_item_entity_drawn(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
position: [f64; 3],
|
||||
velocity: Option<[f64; 3]>,
|
||||
constructor_motion: bool,
|
||||
pickup_delay: u32,
|
||||
) -> [f64; 3] {
|
||||
let (item, count) = (stack.item.clone(), stack.count as i32);
|
||||
self.next_entity += 1;
|
||||
let p = Vec3::new(position[0], position[1], position[2]);
|
||||
// The item's own random, in vanilla's order: Entity's UUID, ItemEntity's
|
||||
// bobOffs and yaw, then (without a given motion) the constructor's motion.
|
||||
// bobOffs and yaw, then (the five-argument constructor) its default motion.
|
||||
let mut own = random_source::entity();
|
||||
let uuid = horizon_client::save::insecure_uuid(&mut own);
|
||||
self.persist.uuids.insert(self.next_entity, uuid);
|
||||
let bob = own.next_float() * std::f32::consts::PI * 2.0;
|
||||
let yaw = own.next_float() * 360.0;
|
||||
let velocity = velocity.unwrap_or_else(|| {
|
||||
let constructed = constructor_motion.then(|| {
|
||||
let x = own.next_double() * 0.2 - 0.1;
|
||||
[x, 0.2, own.next_double() * 0.2 - 0.1]
|
||||
});
|
||||
let velocity = velocity
|
||||
.or(constructed)
|
||||
.expect("an item entity without a velocity takes the constructor's");
|
||||
self.dropped.push(ItemEntity {
|
||||
rotation: [yaw, 0.0],
|
||||
pickup_delay: i64::from(pickup_delay),
|
||||
@@ -850,6 +885,100 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod destroyed_contents_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// The motion of each spilled stack, in the order the spill made them.
|
||||
fn spilled_motions(game: &Game<'_>) -> Vec<[f64; 3]> {
|
||||
game.dropped
|
||||
.iter()
|
||||
.map(|item| [item.velocity.x, item.velocity.y, item.velocity.z])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ItemUtils.onContainerDestroyed` makes each stack with
|
||||
/// `new ItemEntity(level, x, y, z, stack)`, whose motion is 0.2 up and
|
||||
/// ±0.1 sideways from the *item's* random (ItemEntity.java:61-66), and
|
||||
/// draws nothing from the level's. Every destroyed container funnels here:
|
||||
/// lava, fire and cactus in the item's environment tick, an explosion, and
|
||||
/// the damage command.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn spilled_stacks_rise_from_their_own_randoms() {
|
||||
let mut game = server_game();
|
||||
let at = Vec3::new(8.5, 70.0, 8.5);
|
||||
let stacks = ["minecraft:stone", "minecraft:dirt", "minecraft:glass"]
|
||||
.into_iter()
|
||||
.map(|id| (ItemStack::new(id, 1), at))
|
||||
.collect();
|
||||
let mut level = game.world.random.clone();
|
||||
game.spawn_destroyed_contents(stacks);
|
||||
let motions = spilled_motions(&game);
|
||||
assert_eq!(motions.len(), 3);
|
||||
for motion in &motions {
|
||||
assert_eq!(motion[1], 0.2, "the four-argument constructor rises 0.2");
|
||||
assert!((-0.1..0.1).contains(&motion[0]), "{motion:?}");
|
||||
assert!((-0.1..0.1).contains(&motion[2]), "{motion:?}");
|
||||
}
|
||||
// Three stacks, three different sideways motions: each drew its own.
|
||||
assert_ne!(motions[0], motions[1]);
|
||||
assert_ne!(motions[1], motions[2]);
|
||||
assert_ne!(motions[0], motions[2]);
|
||||
// The spill leaves the level's random alone.
|
||||
assert_eq!(game.world.random.next_double(), level.next_double());
|
||||
assert_eq!(game.world.random.next_double(), level.next_double());
|
||||
}
|
||||
|
||||
/// The damage command is one of those callers: killing a shulker box item
|
||||
/// spills its contents with the same upward motion.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_damaged_shulker_box_spills_its_stacks_upward() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
let feet = Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
game.update_chunk_tickets();
|
||||
let boxed = horizon_client::item_data::parse_stack(
|
||||
r#"{id:"minecraft:shulker_box",count:1,components:{"minecraft:container":[
|
||||
{slot:0,item:{id:"minecraft:stone",count:2}},
|
||||
{slot:4,item:{id:"minecraft:dirt",count:1}},
|
||||
{slot:8,item:{id:"minecraft:glass",count:3}}]}}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
game.spawn_item(boxed, [8.5, 70.0, 8.5], [0.0; 3], 0);
|
||||
assert_eq!(game.dropped.len(), 1);
|
||||
assert!(game.run_command("damage @e[type=minecraft:item,limit=1] 100 minecraft:generic"));
|
||||
let mut spilled: Vec<_> = game
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|item| (item.stack.item.to_string(), item.stack.count))
|
||||
.collect();
|
||||
spilled.sort();
|
||||
assert_eq!(
|
||||
spilled,
|
||||
[
|
||||
("minecraft:dirt".to_owned(), 1),
|
||||
("minecraft:glass".to_owned(), 3),
|
||||
("minecraft:stone".to_owned(), 2)
|
||||
]
|
||||
);
|
||||
for item in &game.dropped {
|
||||
assert_eq!(item.velocity.y, 0.2, "{item:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod escape_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
@@ -867,9 +996,14 @@ mod escape_tests {
|
||||
let mut game = server_game();
|
||||
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
// Entity-ticking chunks also need the surrounding loaded chunks.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(8, 8));
|
||||
let stone = game
|
||||
@@ -883,13 +1017,17 @@ mod escape_tests {
|
||||
game.spawn_item(stack, [8.5, 64.4, 8.5], [0.0, 0.0, 0.0], 10);
|
||||
let item = game.dropped.last().unwrap();
|
||||
let expected = f64::from(item.random.clone().next_float() * 0.2 + 0.1);
|
||||
let mut own = item.random.clone();
|
||||
own.next_float();
|
||||
let mut level = game.world.random.clone();
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
game.tick_items();
|
||||
let item = game.dropped.last().unwrap();
|
||||
// Up is the nearest open side from the middle of the block.
|
||||
// The push replaces the fall's velocity; then the 0.98 drag.
|
||||
assert_eq!(item.velocity.y, expected * 0.98);
|
||||
// Entity.getAirDrag returns 0.98F, promoted to double for the drag.
|
||||
assert_eq!(item.velocity.y, expected * f64::from(0.98_f32));
|
||||
assert_eq!(item.random.clone().next_int(), own.next_int());
|
||||
assert_eq!(game.world.random.next_float(), level.next_float());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
//! The dropped items' own moves, worked out ahead of the item loop on the tick's helper threads
|
||||
//! while the parallel tick is on (`horizon_client::parallel`).
|
||||
//!
|
||||
//! An item's move (`ItemEntity.tick` up to its merge) reads only itself, the level's blocks, the
|
||||
//! obstacle boxes and the border, and the item loop changes none of them: so an item's move worked
|
||||
//! out on a copy before the loop is the move its turn would make. Its escape from inside a block
|
||||
//! draws the item's own random, which the copy carries. A merge before its turn can change its
|
||||
//! count, age and pickup delay: such an item moves at its turn instead. With a native mod loaded
|
||||
//! (its hooks see every item) or a mod listening to the round, every item moves at its turn.
|
||||
use super::super::{boats::fall_reset, entity::Entity as _, native_ticks};
|
||||
use super::*;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
entities::{ItemEntity, ItemSplash, ItemStep, ObstacleGrid},
|
||||
motion::MotionResources,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3, border::BorderGeometry};
|
||||
use horizon_world::{block_collision::BlockCollisionRegistry, registry::BlockRegistry};
|
||||
|
||||
/// From this many items their moves go to the helper threads.
|
||||
const MIN_ITEMS: usize = 256;
|
||||
|
||||
/// What an item's own move reads.
|
||||
pub(in crate::game) struct ItemTables<'a> {
|
||||
world: &'a World,
|
||||
facts: &'a BlockFacts,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
registry: &'a BlockRegistry,
|
||||
border: &'a BorderGeometry,
|
||||
}
|
||||
|
||||
impl<'w> Game<'w> {
|
||||
pub(in crate::game) fn item_tables(&self) -> ItemTables<'_> {
|
||||
ItemTables {
|
||||
world: &self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
motion: self.res.motion(),
|
||||
registry: self.pack.assets.registry,
|
||||
border: self.border.geometry(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemTables<'_> {
|
||||
/// `ItemEntity.tick` up to its move's vertical contact: the fluids, the water splash, the move
|
||||
/// (pushed out at `escape_speed` when inside a block) and its fall.
|
||||
pub(in crate::game) fn move_item(
|
||||
&self,
|
||||
item: &mut ItemEntity,
|
||||
escape_speed: Option<f32>,
|
||||
entity_boxes: &[Aabb],
|
||||
splash: &mut impl FnMut(ItemSplash),
|
||||
) -> ItemStep {
|
||||
let heights = item.push_by_fluid(self.world, &self.motion.fluids);
|
||||
// Entity.baseTick splashes before moving, except on the first tick.
|
||||
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
|
||||
item.lava_fall(heights.1 > 0.0);
|
||||
let step = item.tick_move_before_block_effects(
|
||||
self.world,
|
||||
self.facts,
|
||||
self.collision,
|
||||
escape_speed.is_some(),
|
||||
escape_speed,
|
||||
heights,
|
||||
entity_boxes,
|
||||
Some(self.border),
|
||||
Some(&self.motion.blocks),
|
||||
);
|
||||
item.check_fall(&step, |fall, from, moved| {
|
||||
fall_reset(self.world, self.motion, self.facts, fall, from, moved)
|
||||
});
|
||||
step
|
||||
}
|
||||
/// The rest of the move after its vertical contact: block effects, bubble columns, settling
|
||||
/// and the water entry. Whether the item lives on.
|
||||
pub(in crate::game) fn settle_item(
|
||||
&self,
|
||||
item: &mut ItemEntity,
|
||||
no_physics: bool,
|
||||
step: ItemStep,
|
||||
splash: &mut impl FnMut(ItemSplash),
|
||||
) -> bool {
|
||||
item.finish_move_block_effects(
|
||||
self.world,
|
||||
self.facts,
|
||||
no_physics,
|
||||
Some(&self.motion.blocks),
|
||||
step,
|
||||
);
|
||||
item.push_by_bubble_column(self.world, self.registry, self.facts, self.collision);
|
||||
let alive = item.tick_settle(self.world, self.facts, no_physics, step);
|
||||
// ItemEntity.tick refreshes immersion and splashes on water entry.
|
||||
let (water, _) = item.push_by_fluid(self.world, &self.motion.fluids);
|
||||
item.water_entry(water > 0.0, false, splash);
|
||||
alive
|
||||
}
|
||||
}
|
||||
|
||||
/// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the box around an item that its
|
||||
/// move reads others' boxes in (a block of slack for the next velocity).
|
||||
pub(super) fn reach(item: &ItemEntity) -> Aabb {
|
||||
let v = item.velocity;
|
||||
item.bounds()
|
||||
.inflate(Vec3::new(v.x.abs() + 1.0, v.y.abs() + 1.0, v.z.abs() + 1.0))
|
||||
}
|
||||
|
||||
/// An item's move as its turn makes it: without physics (inside a block), alive, discarded, and
|
||||
/// its splashes.
|
||||
pub(super) type Moved = (bool, bool, bool, Vec<ItemSplash>);
|
||||
|
||||
/// One item's move, worked out on a copy.
|
||||
pub(super) struct Ahead {
|
||||
moved: ItemEntity,
|
||||
result: Moved,
|
||||
/// What a merge before its turn can change: its count, age and pickup delay.
|
||||
started: (u32, i32, i64),
|
||||
}
|
||||
|
||||
fn started(item: &ItemEntity) -> (u32, i32, i64) {
|
||||
(item.stack.count, item.age, item.pickup_delay)
|
||||
}
|
||||
|
||||
/// `item`'s whole move on a server with no native mod, from the tables alone.
|
||||
fn move_alone(
|
||||
tables: &ItemTables,
|
||||
obstacles: &ObstacleGrid,
|
||||
item: &mut ItemEntity,
|
||||
found: &mut Vec<u32>,
|
||||
near: &mut Vec<Aabb>,
|
||||
) -> Moved {
|
||||
obstacles.near_shared(reach(item), found, near);
|
||||
let no_physics = item.inside_block(tables.world, tables.collision, near, Some(tables.border));
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut splashes = Vec::new();
|
||||
let step = tables.move_item(item, escape_speed, near, &mut |s| splashes.push(s));
|
||||
if step.vertical_contact.is_some() && item.stack.count == 0 {
|
||||
return (no_physics, false, true, splashes);
|
||||
}
|
||||
let alive = tables.settle_item(item, no_physics, step, &mut |s| splashes.push(s));
|
||||
(no_physics, alive, false, splashes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's choice of the path, over the parallel tick's switch.
|
||||
static AHEAD: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
|
||||
/// The moves a test's tick put in place, and those a merge made it refuse.
|
||||
static TAKEN: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
/// Whether the moves are worked out ahead: while the parallel tick is on.
|
||||
fn ahead() -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(ahead) = AHEAD.with(std::cell::Cell::get) {
|
||||
return ahead;
|
||||
}
|
||||
horizon_client::parallel::lanes() > 1
|
||||
}
|
||||
|
||||
/// The moves worked out ahead, by the item's place in the loop.
|
||||
pub(super) struct Moves(Vec<Option<Ahead>>);
|
||||
|
||||
impl Moves {
|
||||
/// The moves of the items that will tick (`ticks`), on the helper threads; none while the
|
||||
/// parallel tick is off, for few items, or with a mod that could see an item's move.
|
||||
pub(super) fn work_out(
|
||||
game: &Game,
|
||||
items: &[ItemEntity],
|
||||
obstacles: &ObstacleGrid,
|
||||
round: &native_ticks::TickRound,
|
||||
ticks: impl Fn(&ItemEntity) -> bool,
|
||||
) -> Self {
|
||||
if !ahead()
|
||||
|| items.len() < MIN_ITEMS
|
||||
|| game.remote.is_some()
|
||||
|| round.listening()
|
||||
|| !horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
return Self(Vec::new());
|
||||
}
|
||||
let todo: Vec<usize> = (0..items.len()).filter(|&i| ticks(&items[i])).collect();
|
||||
let tables = game.item_tables();
|
||||
let done = horizon_client::parallel::par_map(&todo, MIN_ITEMS / 4, |part, out| {
|
||||
let (mut found, mut near) = (Vec::new(), Vec::new());
|
||||
for &i in part {
|
||||
let mut moved = items[i].clone();
|
||||
let result = move_alone(&tables, obstacles, &mut moved, &mut found, &mut near);
|
||||
let started = started(&items[i]);
|
||||
out.push((
|
||||
i,
|
||||
Ahead {
|
||||
moved,
|
||||
result,
|
||||
started,
|
||||
},
|
||||
));
|
||||
}
|
||||
});
|
||||
let mut moves: Vec<Option<Ahead>> = (0..items.len()).map(|_| None).collect();
|
||||
for (i, a) in done {
|
||||
moves[i] = Some(a);
|
||||
}
|
||||
Self(moves)
|
||||
}
|
||||
/// Item `i`'s move, put in place, when it was worked out and no merge changed the item since.
|
||||
/// Under `HORIZON_PARALLEL_TICK=verify` the move is made again at its turn, on a copy, and a
|
||||
/// difference is logged.
|
||||
pub(super) fn take(
|
||||
&mut self,
|
||||
i: usize,
|
||||
item: &mut ItemEntity,
|
||||
again: impl FnOnce(&mut ItemEntity) -> Moved,
|
||||
) -> Option<Moved> {
|
||||
let a = self.0.get_mut(i)?.take()?;
|
||||
let fits = a.started == started(item);
|
||||
#[cfg(test)]
|
||||
TAKEN.with(|t| {
|
||||
let (taken, refused) = t.get();
|
||||
t.set((taken + usize::from(fits), refused + usize::from(!fits)));
|
||||
});
|
||||
if !fits {
|
||||
return None;
|
||||
}
|
||||
if horizon_client::parallel::parallel_tick() == 2 {
|
||||
let mut serial = item.clone();
|
||||
let expected = again(&mut serial);
|
||||
check(item.id, &serial, &expected, &a.moved, &a.result);
|
||||
}
|
||||
*item = a.moved;
|
||||
Some(a.result)
|
||||
}
|
||||
}
|
||||
|
||||
/// `HORIZON_PARALLEL_TICK=verify`: an item's move worked out ahead against the one at its turn.
|
||||
fn check(id: u32, serial: &ItemEntity, expected: &Moved, ahead: &ItemEntity, got: &Moved) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static CHECKED: AtomicU64 = AtomicU64::new(0);
|
||||
static DIFFERED: AtomicU64 = AtomicU64::new(0);
|
||||
if format!("{serial:?} {expected:?}") != format!("{ahead:?} {got:?}") {
|
||||
DIFFERED.fetch_add(1, Ordering::Relaxed);
|
||||
log::error!("parallel tick: item {id}'s move worked out ahead differs from its turn's");
|
||||
}
|
||||
let n = CHECKED.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if n == 1 || n.is_multiple_of(10_000) {
|
||||
log::info!(
|
||||
"parallel tick: {n} item moves worked out ahead checked, {} differed",
|
||||
DIFFERED.load(Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::native_machines::tests::server_game;
|
||||
use super::{AHEAD, TAKEN};
|
||||
use horizon_client::{chunk::Chunk, items::ItemStack};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// One item tick with the moves worked out ahead leaves every item, and the level's random,
|
||||
/// as the tick that moves each item at its turn: items escaping a block (their own random),
|
||||
/// floating in water, falling, and merging into a later item before its turn (whose move
|
||||
/// is then made at its turn).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn moves_worked_out_ahead_match_those_made_at_each_turn() {
|
||||
let mut game = server_game();
|
||||
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
// `ChunkMap.prepareEntityTickingChunk`: the chunks within 2 are loaded too.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(8, 8));
|
||||
game.border.set_center([8.0, 8.0]);
|
||||
game.border.set_size(16.0);
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
let mut escaping = Vec::new();
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
|
||||
let stack = |count| ItemStack::new("minecraft:stone", count);
|
||||
match (x + z) % 5 {
|
||||
0 => {
|
||||
game.world.set(x, 65, z, stone).unwrap();
|
||||
game.spawn_item(stack(1), [fx, 65.3, fz], [0.0; 3], 10);
|
||||
escaping.push(game.dropped.last().unwrap().id);
|
||||
}
|
||||
1 => {
|
||||
game.world.set(x, 65, z, water).unwrap();
|
||||
game.spawn_item(stack(1), [fx, 65.5, fz], [0.0; 3], 10);
|
||||
}
|
||||
2 => {
|
||||
game.spawn_item(stack(1), [fx, 66.0, fz], [0.0; 3], 0);
|
||||
game.spawn_item(stack(5), [fx, 66.0, fz], [0.0; 3], 0);
|
||||
}
|
||||
_ => game.spawn_item(stack(1), [fx, 67.0, fz], [0.1, 0.2, -0.1], 10),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Real solid-entity boxes, not a stubbed obstacle query. One item is
|
||||
// caught inside the boat; another falls onto its top this tick.
|
||||
game.next_entity += 1;
|
||||
let boat = super::super::super::boats::Boat::new(
|
||||
game.next_entity,
|
||||
Arc::from("oak"),
|
||||
Vec3::new(8.5, 80.0, 8.5),
|
||||
0.0,
|
||||
);
|
||||
let boat_top = boat.bounds().max_position().y;
|
||||
game.boats.push(boat);
|
||||
game.spawn_item(
|
||||
ItemStack::new("minecraft:iron_ingot", 1),
|
||||
[8.5, 80.2, 8.5],
|
||||
[0.0; 3],
|
||||
10,
|
||||
);
|
||||
let caught = game.dropped.last().unwrap().id;
|
||||
escaping.push(caught);
|
||||
game.spawn_item(
|
||||
ItemStack::new("minecraft:gold_ingot", 1),
|
||||
[8.5, 81.0, 8.5],
|
||||
[0.0, -1.0, 0.0],
|
||||
10,
|
||||
);
|
||||
let landing = game.dropped.last().unwrap().id;
|
||||
// The border cuts the box even though the centre is still inside it.
|
||||
game.spawn_item(
|
||||
ItemStack::new("minecraft:diamond", 1),
|
||||
[15.99, 80.0, 8.5],
|
||||
[0.0; 3],
|
||||
10,
|
||||
);
|
||||
let border = game.dropped.last().unwrap().id;
|
||||
escaping.push(border);
|
||||
let boxes = game.item_entity_collisions();
|
||||
for id in [caught, border] {
|
||||
let item = game.dropped.iter().find(|i| i.id == id).unwrap();
|
||||
assert!(item.inside_block(
|
||||
&game.world,
|
||||
game.collision,
|
||||
&boxes,
|
||||
Some(game.border.geometry())
|
||||
));
|
||||
assert!(!item.inside_block(&game.world, game.collision, &[], None));
|
||||
}
|
||||
for item in game.dropped.iter_mut() {
|
||||
item.ticks = 39;
|
||||
}
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
let (items, random) = (game.dropped.clone(), game.world.random.clone());
|
||||
// Each merge-written field independently invalidates a prepared move.
|
||||
// Rejecting it must not overwrite the merge or advance the item's RNG.
|
||||
AHEAD.with(|a| a.set(Some(true)));
|
||||
let obstacles = super::ObstacleGrid::new(boxes);
|
||||
for changed in 0..3 {
|
||||
let mut moves = super::Moves::work_out(
|
||||
&game,
|
||||
&items,
|
||||
&obstacles,
|
||||
&super::native_ticks::TickRound::default(),
|
||||
|_| true,
|
||||
);
|
||||
let mut item = items[0].clone();
|
||||
match changed {
|
||||
0 => item.stack.count += 1,
|
||||
1 => item.age += 1,
|
||||
_ => item.pickup_delay += 1,
|
||||
}
|
||||
let before = format!("{item:?}");
|
||||
assert!(
|
||||
moves
|
||||
.take(0, &mut item, |_| panic!("invalidated move was verified"))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(format!("{item:?}"), before);
|
||||
}
|
||||
let mut runs = Vec::new();
|
||||
// First use the real environment switch, then serial/ahead/serial
|
||||
// overrides. This also checks switch-off behavior above the threshold.
|
||||
for ahead in [None, Some(false), Some(true), Some(false)] {
|
||||
game.dropped = items.clone();
|
||||
game.world.random = random.clone();
|
||||
AHEAD.with(|a| a.set(ahead));
|
||||
TAKEN.with(|t| t.set((0, 0)));
|
||||
game.tick_items();
|
||||
// Independently advance just the escaping items' starting randoms.
|
||||
// The helper copy must consume exactly one float and return that
|
||||
// state at the turn; ordinary moves must consume none of it.
|
||||
for item in game.dropped.iter() {
|
||||
let before = items.iter().find(|i| i.id == item.id).unwrap();
|
||||
let mut expected = before.random.clone();
|
||||
if escaping.contains(&item.id) {
|
||||
expected.next_float();
|
||||
}
|
||||
assert_eq!(
|
||||
item.random.clone().next_int(),
|
||||
expected.next_int(),
|
||||
"item {}",
|
||||
item.id
|
||||
);
|
||||
}
|
||||
let landed = game.dropped.iter().find(|i| i.id == landing).unwrap();
|
||||
assert_eq!(landed.position.y, boat_top);
|
||||
assert!(landed.on_ground);
|
||||
assert_eq!(format!("{:?}", game.world.random), format!("{random:?}"));
|
||||
let left: Vec<String> = game.dropped.iter().map(|i| format!("{i:?}")).collect();
|
||||
runs.push((
|
||||
left,
|
||||
format!("{:?}", game.world.random),
|
||||
TAKEN.with(|t| t.get()),
|
||||
));
|
||||
}
|
||||
AHEAD.with(|a| a.set(None));
|
||||
assert!(runs[0].0.len() < items.len(), "some items merged");
|
||||
assert_eq!(runs[0].0, runs[1].0);
|
||||
assert_eq!(runs[0].1, runs[1].1);
|
||||
assert_eq!(runs[0].0, runs[2].0);
|
||||
assert_eq!(runs[0].1, runs[2].1);
|
||||
assert_eq!(runs[0].0, runs[3].0);
|
||||
assert_eq!(runs[0].1, runs[3].1);
|
||||
assert_eq!(runs[1].2, (0, 0));
|
||||
assert_eq!(runs[3].2, (0, 0));
|
||||
if horizon_client::parallel::lanes() > 1 {
|
||||
assert_eq!(runs[0].2, runs[2].2);
|
||||
} else {
|
||||
assert_eq!(runs[0].2, (0, 0));
|
||||
}
|
||||
// The path under test ran: most moves were put in place, and a merge refused some.
|
||||
let (taken, refused) = runs[2].2;
|
||||
assert!(
|
||||
taken >= 200 && refused > 0,
|
||||
"{taken} put in place, {refused} refused"
|
||||
);
|
||||
eprintln!(
|
||||
"item work-ahead proof: {} inputs, {} escaping; {taken} taken, {refused} invalidated",
|
||||
items.len(),
|
||||
escaping.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,11 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
|
||||
});
|
||||
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
|
||||
// or going is a block change the sculk sensors hear.
|
||||
if self.world.block(pos[0], pos[1], pos[2]) != state {
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
|
||||
@@ -762,3 +767,69 @@ mod jukebox_component_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod block_change_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming or
|
||||
/// going posts `block_change` at the box, so sculk sensors hear it.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_disc_change_posts_block_change() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let jukebox = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:jukebox")
|
||||
.unwrap();
|
||||
game.world.set(2, 4, 3, jukebox).unwrap();
|
||||
super::super::sculk::TEST_POSTED_EVENTS
|
||||
.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
game.set_has_record([2, 4, 3], true);
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|events| {
|
||||
assert!(
|
||||
events.borrow().as_ref().unwrap().contains(&(
|
||||
"minecraft:block_change",
|
||||
[2.5, 4.5, 3.5],
|
||||
None
|
||||
)),
|
||||
"{:?}",
|
||||
events.borrow()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! costs one `Option` check per tick.
|
||||
mod event_timings;
|
||||
mod level_event_targets;
|
||||
mod mob_states;
|
||||
|
||||
use super::{Game, shield::OFFHAND_SLOT};
|
||||
use event_timings::EventTimings;
|
||||
@@ -363,12 +364,12 @@ pub(super) fn baby_index(species: Species) -> Option<u8> {
|
||||
/// own: `mob_meta`'s arms hold these mobs' other entries.
|
||||
fn mob_variant_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
sync: &SyncData,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let (kind, e) = (m.kind.id.as_str(), &m.extra);
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
// Axolotl.DATA_VARIANT (after AgeableMob's two).
|
||||
"minecraft:axolotl" => {
|
||||
@@ -501,7 +502,7 @@ fn mob_variant_meta(
|
||||
/// `defaults` gets the defaults of the entries that can go back to theirs.
|
||||
pub(super) fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
sync: &SyncData,
|
||||
player_entity: Option<i32>,
|
||||
game_time: i64,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
@@ -558,7 +559,7 @@ pub(super) fn mob_meta(
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
|
||||
// swirls of its effects (the ones a mob has are visible, not ambient).
|
||||
meta.extend(
|
||||
hub.effect_meta(
|
||||
sync.effect_meta(
|
||||
(m.extra.effects.iter())
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.map(|e| (e.0, m.extra.ambient_effects.contains(&e.0))),
|
||||
@@ -590,7 +591,7 @@ pub(super) fn mob_meta(
|
||||
}
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
|
||||
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry
|
||||
// after AgeableMob's two). The total `boost()` stored in `counter2`.
|
||||
// The default 0 is not registered: vanilla never writes it back, and the
|
||||
@@ -598,7 +599,7 @@ pub(super) fn mob_meta(
|
||||
if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.counter2)));
|
||||
}
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
mob_variant_meta(m, sync, meta, defaults);
|
||||
mob_raider_meta(m, meta, defaults);
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
@@ -1948,14 +1949,15 @@ impl Game<'_> {
|
||||
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
|
||||
) {
|
||||
out.clear();
|
||||
let mut speeds = super::lan_attributes::SpeedByType::default();
|
||||
let mut states = mob_states::Later::new(self.world.game_time);
|
||||
// ChunkMap.tick sends the changes of an entity outside the
|
||||
// entity-ticking range only when it changed section or needs sync.
|
||||
let levels = self.tickets.levels();
|
||||
let idle_at = |p: horizon_core::geometry::Vec3| {
|
||||
!levels.in_entity_ticking_range((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
|
||||
for (index, m) in (self.mobs.iter().enumerate()).filter(|(_, m)| !m.removed && m.id > after)
|
||||
{
|
||||
// The lead's holder: the player the mob sees, another mob or a
|
||||
// fence knot (game/lan_entities.rs).
|
||||
let player_entity = || {
|
||||
@@ -1986,6 +1988,7 @@ impl Game<'_> {
|
||||
let bare = idle
|
||||
&& (kept.as_deref_mut()).is_some_and(|k| k.bare(hub, m.id as i32, kind, &place))
|
||||
&& !m.needs_sync;
|
||||
let slot = out.as_slice().len();
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), kind);
|
||||
e.position = place.position;
|
||||
(e.yaw, e.pitch, e.head_yaw) = place.rotation;
|
||||
@@ -1999,21 +2002,11 @@ impl Game<'_> {
|
||||
if bare {
|
||||
continue;
|
||||
}
|
||||
mob_meta(
|
||||
m,
|
||||
hub,
|
||||
player_entity(),
|
||||
self.world.game_time,
|
||||
&mut e.meta,
|
||||
&mut e.defaults,
|
||||
);
|
||||
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
|
||||
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
|
||||
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
|
||||
let (sync, sources) = (hub.sync_data(), self.attr_sources());
|
||||
states.state((slot, index), m, player_entity(), sync, sources, e);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut speeds, &mut e.attributes);
|
||||
}
|
||||
states.fill(&self.mobs, hub.sync_data(), self.attr_sources(), out);
|
||||
for d in self.dropped.iter().filter(|d| d.id > after) {
|
||||
let idle = idle_at(d.position);
|
||||
let place = super::lan_idle::Place {
|
||||
@@ -4116,7 +4109,7 @@ mod tests {
|
||||
let hub = meta_hub("RaiderMetaTest");
|
||||
let meta_of = |m: &Mob| {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
|
||||
@@ -4145,7 +4138,7 @@ mod tests {
|
||||
// The same health: the ghoul's own maximum is 40, the zombie's 20.
|
||||
m.health = 10.0;
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(&m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(&m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
for (template, id) in [
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! The part of each mob's state for its clients (`Game::lan_entities`) that reads only the mob and
|
||||
//! the synced tables: its entity data and attribute instances. While the parallel tick is on
|
||||
//! (`horizon_client::parallel`) it is built after the pass, on the tick's helper threads, and put in
|
||||
//! place; otherwise in place during the pass, as one thread always did.
|
||||
use super::super::lan_attributes::{AttrSources, SpeedByType};
|
||||
use super::{mob_freeze_meta, mob_meta};
|
||||
use horizon_client::lan::{
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
};
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_protocol::{entity_data::Value, packets::play::AttributeSnapshot};
|
||||
|
||||
/// From this many mobs the states go to the helper threads.
|
||||
const MIN_MOBS: usize = 1024;
|
||||
|
||||
/// One mob's entity data (`meta`, and the `defaults` its entries can go back to) and attribute
|
||||
/// instances; whether its position is sent precisely.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn mob_state(
|
||||
m: &Mob,
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
player_entity: Option<i32>,
|
||||
game_time: i64,
|
||||
speeds: &mut SpeedByType,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
attributes: &mut Vec<AttributeSnapshot>,
|
||||
) -> bool {
|
||||
mob_meta(m, sync, player_entity, game_time, meta, defaults);
|
||||
mob_freeze_meta(m, meta, defaults);
|
||||
sources.mob_attributes(m, speeds, attributes);
|
||||
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
|
||||
m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& horizon_client::mobs::happy_ghast::on_still_timeout(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's choice of the path, over the parallel tick's switch.
|
||||
static DEFER: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Whether the states wait for the helper threads: while the parallel tick is on.
|
||||
fn deferring() -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(defer) = DEFER.with(std::cell::Cell::get) {
|
||||
return defer;
|
||||
}
|
||||
horizon_client::parallel::lanes() > 1
|
||||
}
|
||||
|
||||
/// The mobs' states of one pass: built in place, or (while the parallel tick is on) the mobs whose
|
||||
/// states wait for the helper threads: (their slot in the states, their index in the mobs, the
|
||||
/// player entity their lead may name).
|
||||
pub(super) struct Later {
|
||||
waiting: Option<Vec<(usize, usize, Option<i32>)>>,
|
||||
game_time: i64,
|
||||
speeds: SpeedByType,
|
||||
}
|
||||
|
||||
impl Later {
|
||||
pub(super) fn new(game_time: i64) -> Self {
|
||||
Self {
|
||||
waiting: deferring().then(Vec::new),
|
||||
game_time,
|
||||
speeds: SpeedByType::default(),
|
||||
}
|
||||
}
|
||||
/// Mob `index`'s state `e`, in `slot` of the states: built now, or left for [`Self::fill`].
|
||||
pub(super) fn state(
|
||||
&mut self,
|
||||
(slot, index): (usize, usize),
|
||||
m: &Mob,
|
||||
player_entity: Option<i32>,
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
e: &mut EntityState,
|
||||
) {
|
||||
if let Some(waiting) = &mut self.waiting {
|
||||
waiting.push((slot, index, player_entity));
|
||||
return;
|
||||
}
|
||||
let (meta, defaults, attributes) = (&mut e.meta, &mut e.defaults, &mut e.attributes);
|
||||
e.precise = mob_state(
|
||||
m,
|
||||
sync,
|
||||
sources,
|
||||
player_entity,
|
||||
self.game_time,
|
||||
&mut self.speeds,
|
||||
meta,
|
||||
defaults,
|
||||
attributes,
|
||||
);
|
||||
}
|
||||
/// The waiting states, built on the helper threads and put in place.
|
||||
pub(super) fn fill(
|
||||
self,
|
||||
mobs: &[Mob],
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
out: &mut States,
|
||||
) {
|
||||
let Some(waiting) = self.waiting else {
|
||||
return;
|
||||
};
|
||||
let game_time = self.game_time;
|
||||
let built = horizon_client::parallel::par_map(&waiting, MIN_MOBS, |part, done| {
|
||||
let mut speeds = SpeedByType::default();
|
||||
for &(slot, index, player) in part {
|
||||
let (mut meta, mut defaults, mut attributes) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let precise = mob_state(
|
||||
&mobs[index],
|
||||
sync,
|
||||
sources,
|
||||
player,
|
||||
game_time,
|
||||
&mut speeds,
|
||||
&mut meta,
|
||||
&mut defaults,
|
||||
&mut attributes,
|
||||
);
|
||||
done.push((slot, meta, defaults, attributes, precise));
|
||||
}
|
||||
});
|
||||
let states = out.as_mut_slice();
|
||||
for (slot, meta, defaults, attributes, precise) in built {
|
||||
let e = &mut states[slot];
|
||||
(e.meta, e.defaults, e.attributes, e.precise) = (meta, defaults, attributes, precise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::lan::{NetConfig, hub::HubConfig};
|
||||
|
||||
/// The states put in place after the pass are those built in place during it, for mobs whose
|
||||
/// entity data and attributes vary: effects, armor, a horse's rolled jump, variants, a baby,
|
||||
/// a happy ghast, and what `/attribute` changed.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn deferred_states_match_those_built_in_place() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::lan::mob_states::tests::deferred_states_match_those_built_in_place",
|
||||
deferred_states_match_those_built_in_place_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn deferred_states_match_those_built_in_place_in_own_process() {
|
||||
let mut game = super::super::super::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for command in [
|
||||
"summon piglin 8.5 80 8.5 {NoAI:1b,PersistenceRequired:1b}",
|
||||
"effect give @e[type=piglin] speed 30 1",
|
||||
"item replace entity @e[type=piglin,limit=1] armor.head with iron_helmet",
|
||||
"summon horse 10.5 80 8.5 {NoAI:1b}",
|
||||
"summon sheep 12.5 80 8.5 {NoAI:1b,Color:3b,Age:-24000}",
|
||||
"summon axolotl 14.5 80 8.5 {NoAI:1b,Variant:2}",
|
||||
"summon happy_ghast 16.5 84 8.5 {NoAI:1b}",
|
||||
"summon pig 18.5 80 8.5 {NoAI:1b}",
|
||||
"summon frog 20.5 80 8.5 {NoAI:1b}",
|
||||
"attribute @e[type=pig,limit=1] minecraft:scale base set 1.5",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
let examples = game.mobs.clone();
|
||||
game.mobs = (0..MIN_MOBS * 2 + 37)
|
||||
.map(|i| {
|
||||
let mut mob = examples[i % examples.len()].clone();
|
||||
mob.id = 10_000 + i as u32;
|
||||
mob.removed = i % 11 == 0;
|
||||
mob
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
game.lan_host(
|
||||
super::super::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig::loopback(0, String::new(), 2),
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 20,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut lan = game.lan.take().unwrap();
|
||||
// The shared tables must retain pack-selected effect colors, not just
|
||||
// vanilla's registry color. This process owns the global pack palette.
|
||||
horizon_client::brewing::set_pack_colors(vec![("speed".into(), 0xAA12_3456)]);
|
||||
let players = Default::default();
|
||||
let mut runs = Vec::new();
|
||||
struct Reset;
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
DEFER.with(|d| d.set(None));
|
||||
}
|
||||
}
|
||||
let _reset = Reset;
|
||||
for after in [0, 10_012] {
|
||||
runs.clear();
|
||||
for defer in [false, true, false] {
|
||||
DEFER.with(|d| d.set(Some(defer)));
|
||||
let mut states = States::default();
|
||||
game.lan_entities(&players, &mut lan.hub, &mut states, false, after, None);
|
||||
runs.push(states.as_slice().to_vec());
|
||||
}
|
||||
let expected_ids: Vec<_> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.id > after)
|
||||
.map(|m| m.id as i32)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
runs[0].iter().map(|e| e.id).collect::<Vec<_>>(),
|
||||
expected_ids
|
||||
);
|
||||
assert!(runs[0].iter().filter(|e| !e.meta.is_empty()).count() >= MIN_MOBS);
|
||||
assert!(runs[0].iter().any(|e| !e.attributes.is_empty()));
|
||||
let colored: Vec<_> = runs[0]
|
||||
.iter()
|
||||
.filter(|e| e.kind == "minecraft:piglin")
|
||||
.collect();
|
||||
assert!(!colored.is_empty());
|
||||
for e in colored {
|
||||
let particles = e.meta.iter().find(|e| e.0 == 10).unwrap();
|
||||
let Value::Particles(particles) = &particles.2 else {
|
||||
panic!("effect particle metadata");
|
||||
};
|
||||
assert_eq!(particles.len(), 1);
|
||||
assert_eq!(
|
||||
particles[0].data,
|
||||
Some(horizon_protocol::entity_data::ParticleData::Color(
|
||||
0xFF12_3456_u32 as i32
|
||||
))
|
||||
);
|
||||
}
|
||||
assert_eq!(runs[0], runs[1], "deferred, after {after}");
|
||||
assert_eq!(runs[0], runs[2], "switch off, after {after}");
|
||||
|
||||
// With deferral off the caller can read the metadata immediately,
|
||||
// before fill, and fill must not replace the vectors it already owns.
|
||||
let m = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| !m.removed && m.id > after)
|
||||
.unwrap();
|
||||
let mut states = States::default();
|
||||
let e = states.add(m.id as i32, 0, &m.kind.id);
|
||||
let mut later = Later::new(game.world.game_time);
|
||||
later.state((0, 0), m, None, lan.hub.sync_data(), game.attr_sources(), e);
|
||||
assert_eq!(e.meta, runs[0][0].meta);
|
||||
assert_eq!(e.defaults, runs[0][0].defaults);
|
||||
assert_eq!(e.attributes, runs[0][0].attributes);
|
||||
let pointers = (e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr());
|
||||
later.fill(
|
||||
&game.mobs,
|
||||
lan.hub.sync_data(),
|
||||
game.attr_sources(),
|
||||
&mut states,
|
||||
);
|
||||
let e = &states.as_slice()[0];
|
||||
assert_eq!(
|
||||
pointers,
|
||||
(e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ fn a_sulfur_cube_sends_its_fuse_at_19_and_from_bucket_at_20() {
|
||||
let hub = meta_hub("SulfurCubeMetaTest");
|
||||
let meta_of = |m: &Mob| {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
|
||||
|
||||
@@ -103,10 +103,32 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a mob's synced attribute instances are read from (`mob_attributes`).
|
||||
pub(super) fn attr_sources(&self) -> AttrSources<'_> {
|
||||
AttrSources {
|
||||
catalog: &self.motion.attributes,
|
||||
items: self.items(),
|
||||
commands: &self.cmds.attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the synced attribute instances are read from: the attribute catalog, the item table and
|
||||
/// what `/attribute` changed. Plain tables, so the tick's helper threads can read them
|
||||
/// (`Game::lan_entities`).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct AttrSources<'a> {
|
||||
catalog: &'a horizon_world::attributes::entity::Catalog,
|
||||
items: &'a horizon_client::items::Items,
|
||||
commands:
|
||||
&'a std::collections::HashMap<Target, horizon_world::attributes::entity::EntityAttributes>,
|
||||
}
|
||||
|
||||
impl AttrSources<'_> {
|
||||
/// The registry id of a client-syncable attribute the entity type has
|
||||
/// (`AttributeMap.getInstance` is null for the others), by name.
|
||||
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
|
||||
let catalog = &self.motion.attributes;
|
||||
let catalog = self.catalog;
|
||||
let id = catalog.id(name)?;
|
||||
(catalog.attribute(id).syncable && catalog.default_base(entity, id).is_some())
|
||||
.then(|| id.registry_id())
|
||||
@@ -114,7 +136,7 @@ impl Game<'_> {
|
||||
|
||||
/// The type's base value of an attribute, as the instance starts.
|
||||
fn attribute_default(&self, entity: &str, name: &str) -> f64 {
|
||||
let catalog = &self.motion.attributes;
|
||||
let catalog = self.catalog;
|
||||
catalog
|
||||
.id(name)
|
||||
.and_then(|id| catalog.default_base(entity, id))
|
||||
@@ -132,7 +154,7 @@ impl Game<'_> {
|
||||
out: &mut Vec<AttributeSnapshot>,
|
||||
) {
|
||||
for (slot, item) in slots {
|
||||
let Some(info) = item.and_then(|i| self.items().get(i)) else {
|
||||
let Some(info) = item.and_then(|i| self.items.get(i)) else {
|
||||
continue;
|
||||
};
|
||||
for (attribute, id, amount, op, group) in &info.modifiers {
|
||||
@@ -306,6 +328,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The instances `/attribute` created in an entity's generic map
|
||||
/// replace the ones derived from its state.
|
||||
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
|
||||
if let Some(map) = self.commands.get(&t) {
|
||||
generic_attributes_into(map, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// An armor stand's synced instances: movement speed (its first
|
||||
/// `aiStep`), its equipment's modifiers and what `/attribute` changed.
|
||||
pub(super) fn stand_attributes(
|
||||
@@ -315,18 +347,19 @@ impl Game<'_> {
|
||||
) -> Vec<AttributeSnapshot> {
|
||||
const KIND: &str = "minecraft:armor_stand";
|
||||
let mut out = Vec::new();
|
||||
if let Some(a) = self.synced_attribute(KIND, "minecraft:movement_speed") {
|
||||
let sources = self.attr_sources();
|
||||
if let Some(a) = sources.synced_attribute(KIND, "minecraft:movement_speed") {
|
||||
instance(
|
||||
&mut out,
|
||||
a,
|
||||
self.attribute_default(KIND, "minecraft:movement_speed"),
|
||||
sources.attribute_default(KIND, "minecraft:movement_speed"),
|
||||
);
|
||||
}
|
||||
let slots = horizon_client::entities::ArmorStand::SLOTS
|
||||
.into_iter()
|
||||
.zip(equipment.iter().map(|s| s.as_ref().map(|s| &s.item)));
|
||||
self.equipment_attributes(KIND, |_| None, slots, &mut out);
|
||||
self.generic_attributes_of(Target::Stand(id), &mut out);
|
||||
sources.equipment_attributes(KIND, |_| None, slots, &mut out);
|
||||
sources.generic_attributes_of(Target::Stand(id), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -372,14 +405,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The instances `/attribute` created in an entity's generic map
|
||||
/// replace the ones derived from its state.
|
||||
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
|
||||
if let Some(map) = self.cmds.attributes.get(&t) {
|
||||
generic_attributes_into(map, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -304,11 +304,10 @@ impl Game<'_> {
|
||||
self.post_attack(index);
|
||||
return;
|
||||
};
|
||||
let weapon = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
|
||||
let items = self.res.items();
|
||||
let weapon = items
|
||||
.weapon_damage(&stack)
|
||||
.map(|damage| (damage, items.get(&stack.item).map_or(0, |i| i.max_damage)));
|
||||
self.native_hurt_enemy_event(&stack, index);
|
||||
let smash = self.smash_attack();
|
||||
if smash {
|
||||
@@ -327,11 +326,10 @@ impl Game<'_> {
|
||||
/// `ItemStack.postHurtEnemy`: `hurtAndBreak(weapon.itemDamagePerAttack)` of
|
||||
/// the weapon in the acting hand.
|
||||
pub(super) fn weapon_wear(&mut self, stack: &ItemStack) {
|
||||
let weapon = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
|
||||
let items = self.res.items();
|
||||
let weapon = items
|
||||
.weapon_damage(stack)
|
||||
.map(|damage| (damage, items.get(&stack.item).map_or(0, |i| i.max_damage)));
|
||||
if let Some((per_attack, max)) = weapon
|
||||
&& max > 0
|
||||
&& !self.creative
|
||||
@@ -392,12 +390,7 @@ impl Game<'_> {
|
||||
if smash {
|
||||
self.mace_smash_self(victim_on_ground);
|
||||
}
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.weapon_damage.is_some())
|
||||
{
|
||||
if self.res.items().weapon_damage(&stack).is_some() {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack_bursts(&stack, "minecraft:player");
|
||||
@@ -421,12 +414,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.smash_knockback(center, Spared::Stand(id), fall);
|
||||
}
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.weapon_damage.is_some())
|
||||
{
|
||||
if self.res.items().weapon_damage(&stack).is_some() {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack_bursts(&stack, "minecraft:armor_stand");
|
||||
|
||||
@@ -12,10 +12,15 @@ use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, HOPPER_SLOTS, ModContainer},
|
||||
items::{ItemStack, Items},
|
||||
mobs::Mob,
|
||||
save,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{geometry::Direction, position::BlockPos, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::{
|
||||
level::{Behaviours, LevelAccess},
|
||||
nbt::Tag,
|
||||
@@ -224,6 +229,8 @@ struct Containers<'a> {
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
|
||||
brewing: Option<&'a horizon_client::brewing::Brewing>,
|
||||
/// The level's mobs, for the cat half of `ChestBlock.isChestBlockedAt`.
|
||||
mobs: &'a [Mob],
|
||||
}
|
||||
impl Containers<'_> {
|
||||
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
@@ -251,7 +258,8 @@ impl Containers<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.getBlockContainer` (double chests combined, right half
|
||||
/// first). `blocked_chests`: `ChestBlock.getContainer(.., false)` returns
|
||||
/// nothing for a chest under a redstone conductor.
|
||||
/// nothing for a blocked chest (`isChestBlockedAt`: a redstone conductor
|
||||
/// above it or a cat sitting on it).
|
||||
fn view(&self, pos: Pos, blocked_chests: bool) -> Option<View> {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let name = self.name(state);
|
||||
@@ -308,6 +316,7 @@ impl Containers<'_> {
|
||||
self.behaviours.facts(above).flags
|
||||
& horizon_world::level::data::state_flags::CONDUCTOR
|
||||
!= 0
|
||||
|| cat_sitting_above(self.mobs, p)
|
||||
};
|
||||
if blocked_chests && is_blocked(pos) {
|
||||
return None;
|
||||
@@ -611,20 +620,36 @@ fn bone_meal_stack() -> &'static ItemStack {
|
||||
&STACK
|
||||
}
|
||||
|
||||
/// Comparator input from a container block entity (0 when there is none).
|
||||
pub(super) fn container_signal(
|
||||
world: &World,
|
||||
behaviours: &Behaviours,
|
||||
items: &Items,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
pos: BlockPos,
|
||||
) -> i32 {
|
||||
/// `ChestBlock.isCatSittingOnChest`: a sitting cat whose box meets the block
|
||||
/// above `key` (the box of the block above, not the chest's own).
|
||||
pub(super) fn cat_sitting_above(mobs: &[Mob], key: Pos) -> bool {
|
||||
let area = Aabb::new(
|
||||
Vec3::new(f64::from(key.0), f64::from(key.1 + 1), f64::from(key.2)),
|
||||
Vec3::new(
|
||||
f64::from(key.0 + 1),
|
||||
f64::from(key.1 + 2),
|
||||
f64::from(key.2 + 1),
|
||||
),
|
||||
);
|
||||
mobs.iter().any(|m| {
|
||||
m.species() == horizon_client::mobs::Species::Cat
|
||||
&& m.alive()
|
||||
&& horizon_client::mobs::tame::cat_sitting(m)
|
||||
&& m.bounds().intersects(area)
|
||||
})
|
||||
}
|
||||
|
||||
/// Comparator input from a container block entity (0 when there is none):
|
||||
/// `ChestBlock.getContainer(.., false)`, whose blocked check sees the level's
|
||||
/// cats (`isCatSittingOnChest`).
|
||||
pub(super) fn container_signal(level: &super::GameLevel<'_>, pos: BlockPos) -> i32 {
|
||||
let c = Containers {
|
||||
world,
|
||||
behaviours,
|
||||
items,
|
||||
registry,
|
||||
world: &*level.world,
|
||||
behaviours: level.behaviours,
|
||||
items: level.items,
|
||||
registry: level.registry,
|
||||
brewing: None,
|
||||
mobs: level.entities.mobs,
|
||||
};
|
||||
c.view((pos.x, pos.y, pos.z), true)
|
||||
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
|
||||
@@ -680,6 +705,7 @@ impl Game<'_> {
|
||||
items: self.items(),
|
||||
registry: self.pack.assets.registry,
|
||||
brewing: Some(&self.stations().brewing),
|
||||
mobs: &self.mobs,
|
||||
}
|
||||
}
|
||||
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
|
||||
@@ -2442,3 +2468,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "machines_chest_cat_tests.rs"]
|
||||
mod machines_chest_cat_tests;
|
||||
@@ -0,0 +1,134 @@
|
||||
//! `ChestBlock.isChestBlockedAt` (ChestBlock.java:318-319) is a redstone
|
||||
//! conductor above the chest *or* a cat sitting in the block above it
|
||||
//! (`isCatSittingOnChest`, :327-338). `getAnalogOutputSignal` (:346-348)
|
||||
//! reads `getContainer(.., false)`, whose `combine(.., false)` (:279) uses
|
||||
//! that predicate, so a sitting cat leaves the comparator at 0. A hopper's
|
||||
//! `getContainer(.., true)` ignores the block, and the menu path keeps
|
||||
//! closing as before.
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
const CHEST: Pos = (0, 64, 0);
|
||||
const COMPARATOR: Pos = (1, 64, 0);
|
||||
const WIRE: Pos = (2, 64, 0);
|
||||
|
||||
/// A full chest at `CHEST`, a comparator east of it reading west into it,
|
||||
/// and the wire that carries the comparator's output, on a floor in a
|
||||
/// ticking chunk.
|
||||
fn game() -> Game<'static> {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for x in -1..=1 {
|
||||
for z in -1..=1 {
|
||||
let mut chunk = Chunk::empty(x, z);
|
||||
chunk.lit = true;
|
||||
game.world.chunks.insert((x, z), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(x * 16, 0, z * 16).chunk())
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 64.0, 8.5)).unwrap(),
|
||||
);
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"fill 0 63 0 15 63 15 stone",
|
||||
"setblock 0 64 0 minecraft:chest{Items:[{Slot:0b,id:\"minecraft:stone\",count:64}]}",
|
||||
"setblock 1 64 0 minecraft:comparator[facing=west]",
|
||||
"setblock 2 64 0 minecraft:redstone_wire",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.tickets.levels().block_ticking(0, 0));
|
||||
game
|
||||
}
|
||||
|
||||
/// A block change beside the comparator, so it reads its input again, then
|
||||
/// the ticks that settle the comparator and the wire.
|
||||
fn poke(game: &mut Game<'_>, block: &str) {
|
||||
assert!(game.run_command(&format!(
|
||||
"setblock {} {} {} {block}",
|
||||
COMPARATOR.0,
|
||||
COMPARATOR.1,
|
||||
COMPARATOR.2 + 1
|
||||
)));
|
||||
for _ in 0..4 {
|
||||
game.world.game_time += 1;
|
||||
game.update_chunk_tickets();
|
||||
game.tick_blocks().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn summon_cat(game: &mut Game<'_>, sitting: bool) {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon cat 0.5 65 0.5 {{Sitting:{}b,Tame:1b}}",
|
||||
i8::from(sitting)
|
||||
)));
|
||||
}
|
||||
|
||||
fn wire_power(game: &Game<'_>) -> i32 {
|
||||
let state = game.world.block(WIRE.0, WIRE.1, WIRE.2);
|
||||
let info = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.expect("the wire is a known state");
|
||||
assert_eq!(info.name, "minecraft:redstone_wire");
|
||||
info.properties["power"]
|
||||
.parse()
|
||||
.expect("a wire has a power property")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_sitting_cat_silences_the_comparator_reading_a_chest() {
|
||||
let mut game = game();
|
||||
poke(&mut game, "glass");
|
||||
assert_eq!(wire_power(&game), 1, "a full chest reads 1");
|
||||
|
||||
summon_cat(&mut game, true);
|
||||
poke(&mut game, "stone");
|
||||
assert_eq!(wire_power(&game), 0, "a sitting cat blocks the chest");
|
||||
|
||||
// Standing the cat up gives the signal back.
|
||||
assert!(game.run_command("data merge entity @e[type=minecraft:cat,limit=1] {Sitting:0b}"));
|
||||
poke(&mut game, "dirt");
|
||||
assert_eq!(wire_power(&game), 1, "a standing cat blocks nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_sitting_cat_leaves_the_hopper_and_the_menu_alone() {
|
||||
let mut game = game();
|
||||
summon_cat(&mut game, true);
|
||||
assert!(
|
||||
game.containers().view(CHEST, false).is_some(),
|
||||
"a hopper's getContainer(.., true) ignores the cat"
|
||||
);
|
||||
assert!(
|
||||
game.containers().view(CHEST, true).is_none(),
|
||||
"the comparator's getContainer(.., false) combines to nothing"
|
||||
);
|
||||
assert!(
|
||||
game.chest_menu_blocked(CHEST, "minecraft:chest"),
|
||||
"a chest with a cat on it opens no menu"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_conductor_above_a_chest_still_silences_the_comparator() {
|
||||
let mut game = game();
|
||||
poke(&mut game, "glass");
|
||||
assert_eq!(wire_power(&game), 1);
|
||||
assert!(game.run_command("setblock 0 65 0 stone"));
|
||||
poke(&mut game, "stone");
|
||||
assert_eq!(wire_power(&game), 0, "a conductor above blocks the chest");
|
||||
}
|
||||
@@ -707,6 +707,17 @@ impl MapData {
|
||||
Some(map)
|
||||
}
|
||||
}
|
||||
/// What a read of a map gives: from the cache when the map is loaded, else from its saved file.
|
||||
fn map_read<T>(
|
||||
cached: Option<&MapData>,
|
||||
saved: impl FnOnce() -> Option<MapData>,
|
||||
read: impl Fn(&MapData) -> T,
|
||||
) -> Option<T> {
|
||||
match cached {
|
||||
Some(map) => Some(read(map)),
|
||||
None => saved().map(|map| read(&map)),
|
||||
}
|
||||
}
|
||||
pub(super) struct Maps {
|
||||
pub data: HashMap<i32, MapData>,
|
||||
/// The highest map id handed out (`maps/last_id.dat`), loaded on first use.
|
||||
@@ -926,6 +937,30 @@ impl Game<'_> {
|
||||
fn map_path(&self, name: &str) -> std::path::PathBuf {
|
||||
self.world_root.join("data/minecraft/maps").join(name)
|
||||
}
|
||||
/// `native_map_colors`: the 128 x 128 colour ids of the world's map `map_id`, row by row (vanilla's
|
||||
/// `MapItemSavedData.colors`), from the loaded map or its saved file, which is not loaded here. None for a map the
|
||||
/// world never made or saved, and on a client of a server (as `native_paint_map`).
|
||||
pub(super) fn native_map_colors(&self, map_id: i32) -> Option<Vec<u8>> {
|
||||
self.native_map_read(map_id, |map| map.colors.clone())
|
||||
}
|
||||
/// `native_map_meta`: the scale, dimension and locked flag of the world's map `map_id`, read as
|
||||
/// `native_map_colors` reads it.
|
||||
pub(super) fn native_map_meta(&self, map_id: i32) -> Option<(u8, String, bool)> {
|
||||
self.native_map_read(map_id, |map| (map.scale, map.dimension.clone(), map.locked))
|
||||
}
|
||||
fn native_map_read<T>(&self, map_id: i32, read: impl Fn(&MapData) -> T) -> Option<T> {
|
||||
if self.remote.is_some() || self.world_root.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let saved = || {
|
||||
if self.maps.missing.contains(&map_id) {
|
||||
return None;
|
||||
}
|
||||
horizon_client::save::read_dat(&self.map_path(&format!("{map_id}.dat")))
|
||||
.and_then(|t| MapData::from_nbt(&t))
|
||||
};
|
||||
map_read(self.maps.data.get(&map_id), saved, read)
|
||||
}
|
||||
/// The map's data, loaded from `data/minecraft/maps/<id>.dat` on first use.
|
||||
pub(super) fn map(&mut self, id: i32) -> Option<&mut MapData> {
|
||||
if !self.maps.data.contains_key(&id) && !self.maps.missing.contains(&id) {
|
||||
@@ -1973,6 +2008,8 @@ impl Game<'_> {
|
||||
#[cfg(test)]
|
||||
mod live_frame_tests;
|
||||
#[cfg(test)]
|
||||
mod native_read_tests;
|
||||
#[cfg(test)]
|
||||
mod save_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use super::{MapData, map_read};
|
||||
|
||||
#[test]
|
||||
fn a_painted_picture_reads_back_as_painted() {
|
||||
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
let colors: Vec<u8> = (0..16384).map(|i| (i * 7 % 256) as u8).collect();
|
||||
map.paint(&colors);
|
||||
assert_eq!(
|
||||
map_read(Some(&map), || None, |m| m.colors.clone()),
|
||||
Some(colors)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_map_wins_over_its_saved_file() {
|
||||
let loaded = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
let saved = MapData::fresh(0.0, 0.0, 2, "minecraft:the_end");
|
||||
let scale = |cached: Option<&MapData>| {
|
||||
map_read(
|
||||
cached,
|
||||
|| Some(MapData::fresh(0.0, 0.0, 2, "minecraft:the_end")),
|
||||
|m| m.scale,
|
||||
)
|
||||
};
|
||||
assert_eq!(scale(Some(&loaded)), Some(0));
|
||||
assert_eq!(scale(None), Some(2));
|
||||
assert_eq!(saved.dimension, "minecraft:the_end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_map_neither_loaded_nor_saved_reads_as_none() {
|
||||
assert_eq!(map_read(None, || None, |m| m.locked), None);
|
||||
}
|
||||
@@ -44,12 +44,18 @@ fn game() -> Game<'static> {
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
// Entities tick once every chunk within 2 columns is loaded
|
||||
// (ChunkMap.prepareEntityTickingChunk, #13953).
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
|
||||
game
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,7 @@ mod tests {
|
||||
ingredient: |_, _, _| {},
|
||||
ingredient_test: |_, _, _, _| {},
|
||||
log_line: |_, _, _, _| {},
|
||||
living_death_message: |_, _, _| {},
|
||||
handshake: |_, _, _, _, _| {},
|
||||
});
|
||||
assert!(installed.is_ok());
|
||||
|
||||
@@ -84,7 +84,7 @@ impl Game<'_> {
|
||||
}
|
||||
let camera = self.eye_position().unwrap_or(at);
|
||||
self.particles
|
||||
.fireworks(at, velocity, explosions.to_vec(), camera);
|
||||
.fireworks(at, velocity, explosions.to_vec(), camera, true);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! earlier client level), they return `None`.
|
||||
use super::{Dim, Game, commands::Target};
|
||||
use horizon_client::mod_api::{EntityRef, LevelRef};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
impl Game<'_> {
|
||||
/// What a handle names now, or None for a stale or foreign one.
|
||||
@@ -156,6 +157,53 @@ impl Game<'_> {
|
||||
};
|
||||
Some(LevelRef::new(entity.epoch(), dim.id()))
|
||||
}
|
||||
/// `LivingEntity.hasLineOfSight(target)` for a server mod, with the entities as the events name them by id:
|
||||
/// whether `from_entity` sees `to_entity`. The ray runs from the eyes of `from_entity` to those of `to_entity`
|
||||
/// (`Entity.getEyeY`), against `ClipContext.Block.COLLIDER` shapes with `from_entity` as the collision context
|
||||
/// and no fluids, and it counts only when the two stand in the same level and no more than 128 blocks apart.
|
||||
/// False also for an unknown entity, a client, and a level other than the one being ticked (the level it can
|
||||
/// answer for). API @API:line_of_sight@.
|
||||
pub(super) fn native_has_line_of_sight(&self, from_entity: u32, to_entity: u32) -> bool {
|
||||
if !self.native_level_open(self.dim.id()) {
|
||||
return false;
|
||||
}
|
||||
let (Some(from), Some(to)) = (
|
||||
self.native_entity(from_entity)
|
||||
.and_then(|e| self.native_entity_target(e)),
|
||||
self.native_entity(to_entity)
|
||||
.and_then(|e| self.native_entity_target(e)),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let here = self.dim.id();
|
||||
if self.target_level(from).id() != here || self.target_level(to).id() != here {
|
||||
return false;
|
||||
}
|
||||
let (Some(a), Some(b)) = (self.target_position(from), self.target_position(to)) else {
|
||||
return false;
|
||||
};
|
||||
let from_eye = Vec3::new(a[0], a[1] + self.target_eye_height(from), a[2]);
|
||||
let to_eye = Vec3::new(b[0], b[1] + self.target_eye_height(to), b[2]);
|
||||
self.mob_world_base(&[])
|
||||
.line_of_sight_for(Some(a[1]), from_eye, to_eye)
|
||||
}
|
||||
/// `Level.clip` with `ClipContext.Block.COLLIDER`, no fluids and no entity (`CollisionContext.empty()`), as a
|
||||
/// mod's own ray: true when the segment from `from` to `to` meets no block's collider (a miss). Only the level
|
||||
/// being ticked is answered; another dimension, and a client, give false, since reaching another loaded level
|
||||
/// needs a mutable borrow this read takes without one. API @API:line_of_sight@.
|
||||
pub(super) fn native_clear_between(
|
||||
&self,
|
||||
dimension: &str,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
) -> bool {
|
||||
self.native_level_open(dimension)
|
||||
&& self.mob_world_base(&[]).clear_between(
|
||||
Vec3::new(from[0], from[1], from[2]),
|
||||
Vec3::new(to[0], to[1], to[2]),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved UUID's four ints (`UUIDUtil.uuidFromIntArray`) as one number.
|
||||
@@ -493,3 +541,135 @@ mod client_tests {
|
||||
assert!(made(&game) > before, "event 42 draws the sweat");
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_has_line_of_sight` and `native_clear_between` against real blocks and entities of a server's level.
|
||||
#[cfg(test)]
|
||||
mod line_of_sight_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
|
||||
use horizon_client::mod_api::NativeAuthority;
|
||||
use horizon_world::nbt::Compound;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.native_lifecycle
|
||||
.begin_open(NativeAuthority::IntegratedServer);
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_collider_blocks_sight_and_a_bottom_slab_or_water_does_not() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let a = game
|
||||
.native_summon(dim, "minecraft:pig", [2.5, 200.0, 2.5], Compound::new())
|
||||
.unwrap();
|
||||
let b = game
|
||||
.native_summon(dim, "minecraft:pig", [10.5, 200.0, 2.5], Compound::new())
|
||||
.unwrap();
|
||||
let at = |x: f64| [x, 200.8, 2.5];
|
||||
assert!(game.native_has_line_of_sight(a, b), "open air");
|
||||
assert!(game.native_has_line_of_sight(b, a), "sight is symmetric");
|
||||
assert!(game.native_clear_between(dim, at(2.5), at(10.5)));
|
||||
|
||||
// A bottom slab's collider tops out at 200.5, under the eyes: the ray passes over it.
|
||||
assert!(game.run_command("setblock 6 200 2 minecraft:oak_slab[type=bottom]"));
|
||||
assert!(
|
||||
game.native_has_line_of_sight(a, b),
|
||||
"bottom slab under the eyes"
|
||||
);
|
||||
|
||||
// Water has no collider and fluids do not count (`ClipContext.Fluid.NONE`).
|
||||
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:water"));
|
||||
assert!(game.native_has_line_of_sight(a, b), "water is no collider");
|
||||
|
||||
// Stone's collider covers the eye line: no sight, and the ray is not clear.
|
||||
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:stone"));
|
||||
assert!(!game.native_has_line_of_sight(a, b), "stone wall");
|
||||
assert!(
|
||||
!game.native_has_line_of_sight(b, a),
|
||||
"stone wall, both ways"
|
||||
);
|
||||
assert!(!game.native_clear_between(dim, at(2.5), at(10.5)));
|
||||
// A segment that stops short of the wall is clear.
|
||||
assert!(game.native_clear_between(dim, at(2.5), at(5.5)));
|
||||
|
||||
// Once the wall is gone, the same pair sees each other again.
|
||||
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:air"));
|
||||
assert!(game.native_has_line_of_sight(a, b), "wall removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn unknown_entities_and_other_levels_see_nothing() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let a = game
|
||||
.native_summon(dim, "minecraft:pig", [2.5, 200.0, 2.5], Compound::new())
|
||||
.unwrap();
|
||||
let b = game
|
||||
.native_summon(dim, "minecraft:pig", [10.5, 200.0, 2.5], Compound::new())
|
||||
.unwrap();
|
||||
assert!(
|
||||
!game.native_has_line_of_sight(a, u32::MAX),
|
||||
"no such entity"
|
||||
);
|
||||
assert!(
|
||||
!game.native_has_line_of_sight(a, b + 1000),
|
||||
"no such entity"
|
||||
);
|
||||
|
||||
// A pig in the Nether is in another level: no sight to it, and no ray in that level here.
|
||||
let nether = "minecraft:the_nether";
|
||||
let uuid = (7u128 << 96) | 0x51;
|
||||
assert!(game.run_command(&format!(
|
||||
"execute in {nether} run summon pig 8.5 200 8.5 {{UUID:[I;7,0,0,81],NoAI:1b}}"
|
||||
)));
|
||||
let (_, far, _) = game
|
||||
.native_entity_by_uuid(uuid)
|
||||
.expect("summoned in the Nether");
|
||||
assert!(!game.native_has_line_of_sight(a, far));
|
||||
assert!(!game.native_clear_between(nether, [2.5, 200.8, 2.5], [8.5, 200.8, 8.5]));
|
||||
assert_eq!(game.dim.id(), dim, "the level in place is unchanged");
|
||||
}
|
||||
}
|
||||
@@ -685,6 +685,8 @@ fn catalog_matches_hook_points() {
|
||||
let conn_hooks = free_hooks(include_str!("../lan/conn.rs"));
|
||||
// The log writer's (`horizon_client_lib::native_client_hooks`).
|
||||
let client_hooks = free_hooks(include_str!("../native_client_hooks.rs"));
|
||||
// The tamed mobs' death messages (`horizon_client_lib::living_death`).
|
||||
let death_hooks = free_hooks(include_str!("../living_death.rs"));
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
@@ -709,6 +711,7 @@ fn catalog_matches_hook_points() {
|
||||
.chain(mob_hooks.iter().copied())
|
||||
.chain(conn_hooks.iter().copied())
|
||||
.chain(client_hooks.iter().copied())
|
||||
.chain(death_hooks.iter().copied())
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
@@ -755,6 +758,8 @@ fn catalog_matches_hook_points() {
|
||||
format!("horizon_client_lib::lan::conn::native_{name}")
|
||||
} else if client_hooks.contains(name) {
|
||||
format!("horizon_client_lib::native_client_hooks::native_{name}")
|
||||
} else if death_hooks.contains(name) {
|
||||
format!("horizon_client_lib::living_death::native_{name}")
|
||||
} else if level_hooks.contains(name) {
|
||||
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
|
||||
} else {
|
||||
|
||||
@@ -107,3 +107,61 @@ fn markers_are_listed_with_their_data() {
|
||||
assert_eq!(game.dim.id(), overworld);
|
||||
assert!(game.native_level_markers("minecraft:nowhere").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_shared_lookup_finds_the_level_in_place_and_leaves_the_others() {
|
||||
let mut game = game();
|
||||
let nether = "minecraft:the_nether";
|
||||
let overworld = game.dim.id();
|
||||
let here = "[I;7,0,0,3]";
|
||||
let here_number = (7u128 << 96) | 3;
|
||||
assert!(game.run_command(&format!(
|
||||
"execute in {nether} run summon cow 8.5 70 8.5 {{UUID:{UUID},NoAI:1b}}"
|
||||
)));
|
||||
assert!(game.run_command(&format!("summon cow 3.5 80 3.5 {{UUID:{here},NoAI:1b}}")));
|
||||
let shared = &game;
|
||||
// The level in place is read from `&self`; the nether level is not, and says so with None.
|
||||
let (dimension, id, kind) = shared
|
||||
.native_find_entity_by_uuid(here_number)
|
||||
.expect("found");
|
||||
assert_eq!(
|
||||
(dimension.as_str(), kind.as_str()),
|
||||
(overworld, "minecraft:cow")
|
||||
);
|
||||
assert_eq!(shared.native_find_entity_by_uuid(UUID_NUMBER), None);
|
||||
// The `&mut` lookup still reaches the nether level, and the same entity answers the same way.
|
||||
assert_eq!(
|
||||
game.native_entity_by_uuid(here_number),
|
||||
Some((overworld.to_owned(), id, kind))
|
||||
);
|
||||
assert_eq!(
|
||||
game.native_entity_by_uuid(UUID_NUMBER).map(|f| f.0),
|
||||
Some(nether.to_owned())
|
||||
);
|
||||
assert_eq!(game.dim.id(), overworld);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_entity_in_another_level_has_its_data_read_in_place() {
|
||||
let mut game = game();
|
||||
let nether = "minecraft:the_nether";
|
||||
let overworld = game.dim.id();
|
||||
assert!(game.run_command(&format!(
|
||||
"execute in {nether} run summon cow 8.5 70 8.5 {{UUID:{UUID},NoAI:1b}}"
|
||||
)));
|
||||
let (dimension, id, _) = game.native_entity_by_uuid(UUID_NUMBER).expect("found");
|
||||
let nbt = game
|
||||
.native_entity_nbt_in(&dimension, id)
|
||||
.expect("read in its own level");
|
||||
assert_eq!(nbt.get(&"NoAI".into()), Some(&Tag::Byte(1)));
|
||||
assert!(
|
||||
!nbt.contains_key(&"id".into()),
|
||||
"as native_entity_nbt: no id"
|
||||
);
|
||||
// The level in place is the overworld again, and the id names nothing there.
|
||||
assert_eq!(game.dim.id(), overworld);
|
||||
assert_eq!(game.native_entity_nbt_in(overworld, id), None);
|
||||
assert_eq!(game.native_entity_nbt_in("minecraft:nowhere", id), None);
|
||||
}
|
||||
@@ -21,6 +21,26 @@ impl Game<'_> {
|
||||
/// packet is handled (the hub is taken out then): call it from a tick or
|
||||
/// console hook. API @API:npc_entity_by_uuid@.
|
||||
pub(super) fn native_entity_by_uuid(&mut self, uuid: u128) -> Option<(String, u32, String)> {
|
||||
if let Some(found) = self.native_find_entity_by_uuid(uuid) {
|
||||
return Some(found);
|
||||
}
|
||||
// The levels not in place: each one is put in place to look (`npc_in_level`).
|
||||
let words = horizon_client::lan::playerdata::uuid_ints(uuid);
|
||||
let dims: Vec<&'static str> = self.levels.parked_dims().iter().map(|d| d.id()).collect();
|
||||
for dimension in dims {
|
||||
if let Some(Some((id, kind))) =
|
||||
self.npc_in_level(dimension, |g| g.uuid_holder_here(words))
|
||||
{
|
||||
return Some((dimension.to_owned(), id, kind));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `native_entity_by_uuid` from `&self`, for many read-only lookups: the server's players and the
|
||||
/// entities of the level in place. A level not in place keeps its entities in its own state, and
|
||||
/// reading one means putting it in place (a `&mut` swap), so those levels are left to
|
||||
/// `native_entity_by_uuid`. Same answer as it for these; None on a client. API @API:npc_entity_lookup@.
|
||||
pub(super) fn native_find_entity_by_uuid(&self, uuid: u128) -> Option<(String, u32, String)> {
|
||||
if self.remote.is_some() || self.options.execution_role.is_client() {
|
||||
return None;
|
||||
}
|
||||
@@ -32,37 +52,29 @@ impl Game<'_> {
|
||||
return Some((dimension, id, "minecraft:player".into()));
|
||||
}
|
||||
let words = horizon_client::lan::playerdata::uuid_ints(uuid);
|
||||
let dims: Vec<&'static str> = std::iter::once(self.dim)
|
||||
.chain(self.levels.parked_dims())
|
||||
.map(|d| d.id())
|
||||
.collect();
|
||||
for dimension in dims {
|
||||
let found = self.npc_in_level(dimension, |g| {
|
||||
// The UUIDs the level remembers, checked against its entities: a
|
||||
// remembered id may belong to an entity that is gone.
|
||||
let id = g
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, w)| **w == words)
|
||||
.map(|(id, _)| *id)?;
|
||||
let t = g.level_entity(i32::try_from(id).ok()?)?;
|
||||
if let Target::Mob(m) = t
|
||||
&& !g.mobs.iter().any(|x| x.id == m && !x.removed)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// The mod API has no natural fence knots (#13036).
|
||||
if let Target::Knot(_) = t {
|
||||
return None;
|
||||
}
|
||||
Some((id, g.target_type(t)))
|
||||
});
|
||||
if let Some(Some((id, kind))) = found {
|
||||
return Some((dimension.to_owned(), id, kind));
|
||||
}
|
||||
let (id, kind) = self.uuid_holder_here(words)?;
|
||||
Some((self.dim.id().to_owned(), id, kind))
|
||||
}
|
||||
/// The entity of the level in place that `words` names, with its type: the UUIDs the level
|
||||
/// remembers, checked against its entities (a remembered id may belong to an entity that is gone).
|
||||
fn uuid_holder_here(&self, words: [i32; 4]) -> Option<(u32, String)> {
|
||||
let id = self
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, w)| **w == words)
|
||||
.map(|(id, _)| *id)?;
|
||||
let t = self.level_entity(i32::try_from(id).ok()?)?;
|
||||
if let Target::Mob(m) = t
|
||||
&& !self.mobs.iter().any(|x| x.id == m && !x.removed)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
None
|
||||
// The mod API has no natural fence knots (#13036).
|
||||
if let Target::Knot(_) = t {
|
||||
return None;
|
||||
}
|
||||
Some((id, self.target_type(t)))
|
||||
}
|
||||
/// The `minecraft:marker` entities of the loaded level `dimension`: each one's id, its position
|
||||
/// and its `data` compound (empty for a marker saved without data), as `/data get entity <marker>
|
||||
@@ -117,6 +129,17 @@ impl Game<'_> {
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// The data of entity `id` in the loaded level `dimension`, as `native_entity_nbt` gives it for the
|
||||
/// level in place (`/execute in <dimension> run data get entity`, without the `id`). Read after
|
||||
/// `native_find_entity_by_uuid` or `native_entity_by_uuid` found the entity. None on a client, for a
|
||||
/// level not loaded, and for no such entity. API @API:npc_entity_lookup@.
|
||||
pub(super) fn native_entity_nbt_in(&mut self, dimension: &str, id: u32) -> Option<Compound> {
|
||||
self.npc_in_level(dimension, |g| {
|
||||
let entity = g.native_entity(id)?;
|
||||
g.native_entity_nbt(entity)
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// Turns entity `id` of the loaded level `dimension` to `yaw` and `pitch`
|
||||
/// in degrees, as `/rotate` does (`Entity.forceSetRotation`: `setYRot`,
|
||||
/// `setXRot` clamped to -90..90, its body and head with it), without
|
||||
|
||||
@@ -34,6 +34,10 @@ pub(super) struct RecipeTables {
|
||||
/// recipe_content`) over the window's own: what `Game::recipes` reads on
|
||||
/// a client once they came.
|
||||
synced: Option<horizon_client::crafting::Recipes>,
|
||||
/// The display numbers of those recipes: the server numbers its book's
|
||||
/// displays over its own recipe set, a world's data pack recipes
|
||||
/// included, so the book numbers them the same (`Game::display_index`).
|
||||
pub(super) synced_index: Option<horizon_client::recipe_book::RecipeIndex>,
|
||||
/// The recipe content's parts so far, until its last.
|
||||
incoming: Vec<(String, serde_json::Value)>,
|
||||
/// The types that came before the player did: the mods hear of them
|
||||
@@ -384,9 +388,18 @@ impl Game<'_> {
|
||||
pub(super) fn synced_recipes(&self) -> Option<&horizon_client::crafting::Recipes> {
|
||||
self.recipe_tables.synced.as_ref()
|
||||
}
|
||||
/// The display numbers the recipe book reads: the server's recipes',
|
||||
/// once a server sent them, else the world's own.
|
||||
pub(super) fn display_index(&self) -> &horizon_client::recipe_book::RecipeIndex {
|
||||
match &self.recipe_tables.synced_index {
|
||||
Some(index) => index,
|
||||
None => &self.stations().recipe_index,
|
||||
}
|
||||
}
|
||||
/// A new play session (`Login`): what the last server sent is gone.
|
||||
pub(super) fn forget_synced_recipes(&mut self) {
|
||||
self.recipe_tables.synced = None;
|
||||
self.recipe_tables.synced_index = None;
|
||||
self.recipe_tables.incoming.clear();
|
||||
self.recipe_tables.received = None;
|
||||
self.recipe_tables.tables = None;
|
||||
@@ -453,6 +466,7 @@ impl Game<'_> {
|
||||
let received = std::mem::take(&mut self.recipe_tables.incoming);
|
||||
// No type any more: back on the window's own recipes.
|
||||
if types.is_empty() {
|
||||
self.recipe_tables.synced_index = None;
|
||||
if self.recipe_tables.synced.take().is_some() {
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
@@ -467,11 +481,18 @@ impl Game<'_> {
|
||||
let of = |v: &serde_json::Value| types.iter().any(|t| v["type"].as_str() == Some(t));
|
||||
let mut all: Vec<_> = own.into_iter().filter(|(_, v)| !of(v)).collect();
|
||||
all.extend(received);
|
||||
// The server numbers the displays over these same recipes: a
|
||||
// world's data pack recipes shift the numbers of every recipe
|
||||
// after them, and the book must agree (the server's entries go
|
||||
// over its own by number).
|
||||
let index = horizon_client::recipe_book::RecipeIndex::load_from_sources(&all);
|
||||
horizon_client::crafting::Recipes::from_sources(root, self.items(), &overlay, all)
|
||||
.map(|recipes| (recipes, index))
|
||||
});
|
||||
match built {
|
||||
Ok(recipes) => {
|
||||
Ok((recipes, index)) => {
|
||||
self.recipe_tables.synced = Some(recipes);
|
||||
self.recipe_tables.synced_index = Some(index);
|
||||
// What was made of the window's own recipes is made again.
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
|
||||
@@ -761,6 +761,30 @@ impl Game<'_> {
|
||||
.map(|lan| lan.hub.lan.shared.motd())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Whether the dedicated server runs in online mode (`online-mode` of server.properties):
|
||||
/// its players sign in with their accounts. None off a dedicated server and before it listens.
|
||||
/// API @API:server_settings@.
|
||||
pub(super) fn native_online_mode(&self) -> Option<bool> {
|
||||
Some(online_mode_of(&self.dedicated_lan()?.shared.config))
|
||||
}
|
||||
/// The port the dedicated server listens on: `server-port` of server.properties, or the
|
||||
/// `--port` it was started with. None off a dedicated server and before it listens.
|
||||
/// API @API:server_settings@.
|
||||
pub(super) fn native_server_port(&self) -> Option<u16> {
|
||||
Some(self.dedicated_lan()?.port)
|
||||
}
|
||||
/// How the dedicated server takes its players from a proxy: `"velocity"` when it signs the
|
||||
/// forwarded player information with `forwarding.secret` (offline mode behind Velocity),
|
||||
/// `"none"` when it doesn't; None off a dedicated server and before it listens. The secret
|
||||
/// itself is never returned. API @API:server_settings@.
|
||||
pub(super) fn native_proxy_forwarding(&self) -> Option<&'static str> {
|
||||
Some(proxy_forwarding_of(&self.dedicated_lan()?.shared.config))
|
||||
}
|
||||
/// The listening socket of a dedicated server; None on any other side and before it listens.
|
||||
fn dedicated_lan(&self) -> Option<&horizon_client::lan::Lan> {
|
||||
self.options.dedicated.as_ref()?;
|
||||
Some(&self.lan.as_ref()?.hub.lan)
|
||||
}
|
||||
/// Whether a player has played on the open world before: their player
|
||||
/// data file is there (`players/data/<uuid>.dat`). False when no world
|
||||
/// is open. API @API:join_leave_helpers@.
|
||||
@@ -1511,6 +1535,22 @@ const DEFAULT_WORLDS: [(&str, &str); 3] = [
|
||||
("world_the_end", "minecraft:the_end"),
|
||||
];
|
||||
|
||||
/// The online mode a dedicated server's network config carries: it has an
|
||||
/// `OnlineMode` key (and so signs its logins) only in online mode.
|
||||
fn online_mode_of(config: &horizon_client::lan::NetConfig) -> bool {
|
||||
config.online.is_some()
|
||||
}
|
||||
|
||||
/// The proxy forwarding a dedicated server's network config carries: the secret
|
||||
/// is set only offline behind a proxy (game/dedicated.rs `forwarding_secret`).
|
||||
fn proxy_forwarding_of(config: &horizon_client::lan::NetConfig) -> &'static str {
|
||||
if config.forwarding_secret.is_some() {
|
||||
"velocity"
|
||||
} else {
|
||||
"none"
|
||||
}
|
||||
}
|
||||
|
||||
fn dimension_of_world(world: &str) -> Option<&'static str> {
|
||||
DEFAULT_WORLDS
|
||||
.iter()
|
||||
@@ -2136,4 +2176,22 @@ mod tests {
|
||||
assert_eq!(world_of_dimension("minecraft:overworld"), Some("world"));
|
||||
assert_eq!(world_of_dimension("world"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_settings_follow_the_network_config() {
|
||||
use horizon_client::lan::{NetConfig, OnlineMode};
|
||||
let mut config = NetConfig::loopback(25565, String::new(), 20);
|
||||
// online-mode=false, no proxy: no key, no forwarding secret.
|
||||
assert!(!online_mode_of(&config));
|
||||
assert_eq!(proxy_forwarding_of(&config), "none");
|
||||
// online-mode=false behind Velocity: the secret is set.
|
||||
config.forwarding_secret = Some(b"secret".to_vec());
|
||||
assert!(!online_mode_of(&config));
|
||||
assert_eq!(proxy_forwarding_of(&config), "velocity");
|
||||
// online-mode=true: the server's key, and forwarding stays off.
|
||||
config.forwarding_secret = None;
|
||||
config.online = Some(std::sync::Arc::new(OnlineMode::generate(false).unwrap()));
|
||||
assert!(online_mode_of(&config));
|
||||
assert_eq!(proxy_forwarding_of(&config), "none");
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use std::{
|
||||
};
|
||||
|
||||
use horizon_client::text::Text;
|
||||
use horizon_shader_api::{Api, Blend, Manifest, Pack, check};
|
||||
use horizon_shader_api::{Api, Blend, Manifest, Pack, Primaries, check};
|
||||
|
||||
use super::Game;
|
||||
|
||||
@@ -202,6 +202,12 @@ impl Game<'_> {
|
||||
/// (docs/design/hdr-linear-light.md, steps 3 and 4) isn't served yet. 0.2 itself is drawn
|
||||
/// under `HORIZON_HDR_FRAME=float` (`switch`).
|
||||
fn not_drawn(m: &Manifest, switch: bool) -> Option<String> {
|
||||
if m.primaries == Primaries::Bt2020 {
|
||||
return Some(
|
||||
"[frame] primaries = \"bt2020\" isn't served yet: leave it out, or set \"bt709\""
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if m.blend == Blend::Linear {
|
||||
return Some(
|
||||
"[frame] blend = \"linear\" isn't served yet: leave it out, or set \"gamma\"".into(),
|
||||
@@ -354,6 +360,14 @@ mod tests {
|
||||
assert_eq!(not_drawn(&manifest("0.1", ""), switch), None);
|
||||
let why = not_drawn(&manifest("0.2", "[frame]\nblend = \"linear\"\n"), switch);
|
||||
assert!(why.unwrap().contains("blend = \"linear\" isn't served yet"));
|
||||
let wide = "[frame]\nblend = \"linear\"\nprimaries = \"bt2020\"\n";
|
||||
let why = not_drawn(&manifest("0.2", wide), switch).unwrap();
|
||||
assert!(
|
||||
why.contains("primaries = \"bt2020\" isn't served yet"),
|
||||
"{why}"
|
||||
);
|
||||
let named = manifest("0.2", "[frame]\nprimaries = \"bt709\"\n");
|
||||
assert_eq!(not_drawn(&named, true), None);
|
||||
}
|
||||
let why = not_drawn(&manifest("0.2", ""), false).unwrap();
|
||||
assert!(
|
||||
|
||||
@@ -237,6 +237,69 @@ impl Game<'_> {
|
||||
any
|
||||
}
|
||||
|
||||
/// The vehicle the entity `entity` (a server player or a mob) sits on, by entity id, as
|
||||
/// Bukkit's `Entity.getVehicle` reads it: a boat, a minecart or a mob. None when it rides
|
||||
/// nothing, is not reached (see `native_rider`: inside another player's hook only the acting
|
||||
/// player and mobs are), and on a client. Read-only. API @API:riding_getters@.
|
||||
pub(super) fn native_vehicle_of(&self, entity: u32) -> Option<u32> {
|
||||
if !self.native_serves() {
|
||||
return None;
|
||||
}
|
||||
match self.native_rider(entity)? {
|
||||
Target::Player => self.riding,
|
||||
Target::Guest(conn) => {
|
||||
self.lan
|
||||
.as_ref()?
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)?
|
||||
.vehicle
|
||||
}
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity ids of the server players and mobs riding the vehicle `entity` (a boat, a
|
||||
/// minecart or a mob), in vanilla's `getPassengers` order: the acting player, then the hub's
|
||||
/// guests in the level, then the mobs in the level's order, so the first is the controlling
|
||||
/// passenger where a player rides. Empty when nothing rides it, when it is no vehicle Horizon
|
||||
/// seats riders on, and on a client. Only what `native_rider` reaches: inside another
|
||||
/// player's hook the hub's guests are not listed. Read-only. API @API:riding_getters@.
|
||||
pub(super) fn native_passengers_of(&self, entity: u32) -> Vec<u32> {
|
||||
if !self.native_serves() || self.native_vehicle_target(entity).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut riders = Vec::new();
|
||||
if self.lan_acting.is_some() && self.riding == Some(entity) {
|
||||
riders.push(self.native_acting_entity());
|
||||
}
|
||||
if self.lan_acting.is_none()
|
||||
&& let Some(host) = &self.lan
|
||||
{
|
||||
riders.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
g.placed
|
||||
&& !g.dead
|
||||
&& g.vehicle == Some(entity)
|
||||
&& g.dimension == self.dim.id()
|
||||
})
|
||||
.map(|g| g.entity_id as u32),
|
||||
);
|
||||
}
|
||||
riders.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(entity) && !m.removed && m.alive())
|
||||
.map(|m| m.id),
|
||||
);
|
||||
riders
|
||||
}
|
||||
|
||||
/// The rider an entity id names in the level in place: the acting player, a server player
|
||||
/// out of their turn (only while nobody's is swapped in), or a living mob.
|
||||
fn native_rider(&self, entity: u32) -> Option<Target> {
|
||||
@@ -391,3 +454,113 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
use horizon_client::mod_api::NativeAuthority;
|
||||
use horizon_world::nbt::Compound;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.native_lifecycle
|
||||
.begin_open(NativeAuthority::IntegratedServer);
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game
|
||||
}
|
||||
|
||||
const AT: [f64; 3] = [4.5, 200.0, 4.5];
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mob_on_a_mob_reads_as_mounted_and_clears_after_a_dismount() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let pig = game
|
||||
.native_summon(dim, "minecraft:pig", AT, Compound::new())
|
||||
.unwrap();
|
||||
let chicken = game
|
||||
.native_summon(dim, "minecraft:chicken", AT, Compound::new())
|
||||
.unwrap();
|
||||
assert_eq!(game.native_vehicle_of(chicken), None);
|
||||
assert!(game.native_passengers_of(pig).is_empty());
|
||||
assert!(game.native_mount(chicken, pig));
|
||||
assert_eq!(game.native_vehicle_of(chicken), Some(pig));
|
||||
assert_eq!(game.native_passengers_of(pig), vec![chicken]);
|
||||
assert_eq!(game.native_vehicle_of(pig), None);
|
||||
assert!(game.native_passengers_of(chicken).is_empty());
|
||||
assert!(game.native_dismount(chicken));
|
||||
assert_eq!(game.native_vehicle_of(chicken), None);
|
||||
assert!(game.native_passengers_of(pig).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mob_in_a_boat_is_listed_by_the_boat() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let boat = game
|
||||
.native_summon(dim, "minecraft:oak_boat", AT, Compound::new())
|
||||
.unwrap();
|
||||
let cow = game
|
||||
.native_summon(dim, "minecraft:cow", AT, Compound::new())
|
||||
.unwrap();
|
||||
assert!(game.native_mount(cow, boat));
|
||||
assert_eq!(game.native_vehicle_of(cow), Some(boat));
|
||||
assert_eq!(game.native_passengers_of(boat), vec![cow]);
|
||||
assert_eq!(game.native_vehicle_of(boat), None);
|
||||
assert!(game.native_passengers_of(cow).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_unknown_entity_reads_as_nothing() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let boat = game
|
||||
.native_summon(dim, "minecraft:oak_boat", AT, Compound::new())
|
||||
.unwrap();
|
||||
assert_eq!(game.native_vehicle_of(u32::MAX), None);
|
||||
assert!(game.native_passengers_of(u32::MAX).is_empty());
|
||||
assert!(game.native_passengers_of(boat).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
//! A server keeps one level per dimension. Entity portal processes and
|
||||
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
|
||||
//! saved compound until its destination chunks are loaded.
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity, with_entity};
|
||||
use super::{Dim, Game, Result, Screen, level_flags};
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
@@ -269,6 +269,16 @@ fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep an explicitly captured counter. Old archives without a positive counter
|
||||
/// retain the historical fallback; their migration needs separate evidence.
|
||||
fn preserve_arrival_cooldown(tag: &mut Tag) {
|
||||
if let Tag::Compound(fields) = tag
|
||||
&& !matches!(fields.get(&"PortalCooldown".into()), Some(Tag::Int(delay)) if *delay > 0)
|
||||
{
|
||||
fields.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The world border's extent.
|
||||
pub(super) fn border_bounds(&self) -> BorderBounds {
|
||||
@@ -630,15 +640,15 @@ impl Game<'_> {
|
||||
/// at the start of their tick: an item or mob that stayed in a portal
|
||||
/// goes through (transition time 0) and waits for its dimension.
|
||||
pub(super) fn handle_entity_portals(&mut self) {
|
||||
self.handle_portals(false);
|
||||
self.handle_portals(false, false);
|
||||
}
|
||||
/// `Entity.handlePortal` of the boats and minecarts, which `tick_boats`
|
||||
/// and `tick_carts` move: it runs before them, from the position they
|
||||
/// ended the last tick at, as the start of a vehicle's own tick does.
|
||||
pub(super) fn handle_vehicle_portals(&mut self) {
|
||||
self.handle_portals(true);
|
||||
self.handle_portals(true, false);
|
||||
}
|
||||
fn handle_portals(&mut self, vehicles: bool) {
|
||||
fn handle_portals(&mut self, vehicles: bool, after_move: bool) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -653,51 +663,47 @@ impl Game<'_> {
|
||||
if vehicles {
|
||||
before.clear();
|
||||
}
|
||||
let mut ids: Vec<(u32, bool)> = Vec::new();
|
||||
// ServerLevel.tick ticks only the entities in entity-ticking chunks,
|
||||
// and handlePortal runs in their tick: past the simulation distance an
|
||||
// entity's portal time and cooldown wait, kept as they are.
|
||||
let mut ids: Vec<(u32, bool, i32)> = Vec::new();
|
||||
// Portal handling runs only in entity-ticking chunks; retain frozen
|
||||
// states while their entities are outside the simulation distance.
|
||||
let entity_ticking = self.entity_ticking_area();
|
||||
let mut waiting: Vec<u32> = Vec::new();
|
||||
let tracked = &self.portals.entities;
|
||||
let mut present = std::collections::HashSet::new();
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
let can_sweep = entity.sweeps_portals();
|
||||
let has_portal_state = tracked.contains_key(&entity.id());
|
||||
if has_portal_state {
|
||||
present.insert(entity.id());
|
||||
}
|
||||
// Entity.processPortalCooldown still runs for entities this
|
||||
// portal pass cannot send, such as an ender pearl.
|
||||
if entity.is_vehicle() == vehicles && (can_sweep || has_portal_state) {
|
||||
if entity.is_vehicle() == vehicles
|
||||
&& entity.portal_after_move() == after_move
|
||||
&& (can_sweep || has_portal_state)
|
||||
{
|
||||
let at = entity.position();
|
||||
if !entity_ticking(at.x.floor() as i32, at.z.floor() as i32) {
|
||||
if has_portal_state {
|
||||
waiting.push(entity.id());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if can_sweep && entity.portal_from().is_none() {
|
||||
before.insert(entity.id(), entity.position());
|
||||
}
|
||||
if has_portal_state {
|
||||
ids.push((entity.id(), can_sweep && entity.can_use_portal(self)));
|
||||
ids.push((
|
||||
entity.id(),
|
||||
can_sweep && entity.can_use_portal(self),
|
||||
entity.dimension_changing_delay(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
self.portals.before = before;
|
||||
let present: std::collections::HashSet<u32> = ids
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.chain(waiting.iter().copied())
|
||||
.collect();
|
||||
// Each pass keeps the other group's states.
|
||||
let (boats, carts) = (&self.boats, &self.carts);
|
||||
self.portals.entities.retain(|id, _| {
|
||||
let vehicle = boats.iter().any(|b| b.id == *id) || carts.iter().any(|c| c.id == *id);
|
||||
present.contains(id) || vehicle != vehicles
|
||||
});
|
||||
for (id, allowed) in ids {
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
for (id, allowed, delay) in ids {
|
||||
let Some(state) = self.portals.entities.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(entry) = state.handle(allowed, 0, ENTITY_DELAY) {
|
||||
if let Some(entry) = state.handle(allowed, 0, delay) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.cooldown == 0 && state.process.is_none() {
|
||||
@@ -739,6 +745,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The live entity's `getDimensionChangingDelay`; unknown ids use Entity's default.
|
||||
pub(super) fn entity_portal_delay(&self, id: u32) -> i32 {
|
||||
self.entity_handles()
|
||||
.find_map(|handle| {
|
||||
with_entity!(self, handle, entity => {
|
||||
(entity.id() == id).then(|| entity.dimension_changing_delay())
|
||||
})
|
||||
})
|
||||
.unwrap_or(ENTITY_DELAY)
|
||||
}
|
||||
/// `Entity.saveWithoutId`: the modeled non-arrow projectile codecs
|
||||
/// share the live cooldown between command reads and chunk writes.
|
||||
pub(super) fn save_projectile_portal_cooldown(
|
||||
@@ -760,21 +776,23 @@ impl Game<'_> {
|
||||
/// `Entity.setAsInsidePortal` on an entity that is still on its portal
|
||||
/// cooldown, whatever the portal: the cooldown restarts and it stays.
|
||||
pub(super) fn restart_portal_cooldown(&mut self, id: u32) -> bool {
|
||||
let delay = self.entity_portal_delay(id);
|
||||
match self.portals.entities.get_mut(&id) {
|
||||
Some(state) if state.cooldown > 0 => {
|
||||
state.cooldown = ENTITY_DELAY;
|
||||
state.cooldown = delay;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
|
||||
/// portal: it stands in the portal for 300 ticks without going through.
|
||||
/// portal: it waits for its entity-specific cooldown without going through.
|
||||
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
|
||||
let delay = self.entity_portal_delay(id);
|
||||
self.portals.entities.insert(
|
||||
id,
|
||||
PortalState {
|
||||
cooldown: ENTITY_DELAY,
|
||||
cooldown: delay,
|
||||
process: None,
|
||||
},
|
||||
);
|
||||
@@ -844,6 +862,10 @@ impl Game<'_> {
|
||||
/// transition time is 0 too).
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
self.find_portals(None);
|
||||
// AbstractArrow, ThrowableProjectile and AbstractHurtingProjectile apply
|
||||
// block effects before calling Entity.tick, so their refreshed cooldown
|
||||
// is decremented (or their new portal handled) in this same tick.
|
||||
self.handle_portals(false, true);
|
||||
}
|
||||
/// The same for the vehicles server players ride, once `tick_guests` has
|
||||
/// moved them: a ridden vehicle moves with its rider, after the other
|
||||
@@ -878,7 +900,7 @@ impl Game<'_> {
|
||||
{
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
|
||||
}
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
let mut inside: Vec<(u32, BlockPos, i32)> = Vec::new();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
// No loaded section has a portal state in its palette (nearly always): no
|
||||
// sweep below can meet a portal block, so the walk would find nothing.
|
||||
@@ -916,16 +938,16 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
inside.push((id, pos, entity.dimension_changing_delay()));
|
||||
}
|
||||
});
|
||||
self.portals.sections = portal_sections;
|
||||
for (id, pos) in inside {
|
||||
for (id, pos, delay) in inside {
|
||||
self.portals
|
||||
.entities
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.set_inside(pos, ENTITY_DELAY);
|
||||
.set_inside(pos, delay);
|
||||
}
|
||||
}
|
||||
/// The first nether or End portal block an entity box swept through
|
||||
@@ -1034,6 +1056,7 @@ impl Game<'_> {
|
||||
/// entity is saved and removed, and waits for its dimension to load.
|
||||
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
|
||||
let arrivals = self.portals.arrivals.len();
|
||||
let delay = self.entity_portal_delay(id);
|
||||
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let e = &self.dropped[i];
|
||||
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
|
||||
@@ -1203,6 +1226,11 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
if self.portals.arrivals.len() > arrivals {
|
||||
if let Some(arrival) = self.portals.arrivals.last_mut()
|
||||
&& let Tag::Compound(tag) = &mut arrival.tag
|
||||
{
|
||||
tag.insert("PortalCooldown".into(), Tag::Int(delay));
|
||||
}
|
||||
self.native_entity_portal(self.dim.id(), id, [entry.x, entry.y, entry.z]);
|
||||
}
|
||||
}
|
||||
@@ -1301,9 +1329,8 @@ impl Game<'_> {
|
||||
"Rotation".into(),
|
||||
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
|
||||
);
|
||||
// setPortalCooldown before the teleport, copied by restoreFrom.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
preserve_arrival_cooldown(&mut tag);
|
||||
// ServerLevel.addDuringTeleport: a fresh add, not loaded from disk.
|
||||
super::persist::mark_fresh(&mut tag);
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
@@ -1613,6 +1640,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_arrivals_keep_explicit_positive_counters_without_type_migration() {
|
||||
for old in [None, Some(0), Some(-1), Some(5)] {
|
||||
let mut tag = Tag::compound([("id", Tag::String("minecraft:oak_boat".into()))]);
|
||||
if let (Some(old), Tag::Compound(fields)) = (old, &mut tag) {
|
||||
fields.insert("PortalCooldown".into(), Tag::Int(old));
|
||||
}
|
||||
preserve_arrival_cooldown(&mut tag);
|
||||
let arrival = Arrival {
|
||||
plan: Plan::end_portal(Dim::Overworld, BlockPos::new(0, 64, 0), 1.375, 0.5625),
|
||||
tag,
|
||||
};
|
||||
let restored = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
|
||||
let expected = old.filter(|n| *n > 0).unwrap_or(ENTITY_DELAY);
|
||||
assert_eq!(
|
||||
restored.tag.get("PortalCooldown"),
|
||||
Some(&Tag::Int(expected))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_arrivals_keep_their_end_destination_when_saved() {
|
||||
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
|
||||
@@ -1638,3 +1686,7 @@ mod tests {
|
||||
assert!(!arrival_from_tag(&old).unwrap().plan.end);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "portal_cooldown_tests.rs"]
|
||||
mod cooldown_tests;
|
||||
@@ -749,7 +749,7 @@ impl Game<'_> {
|
||||
pub(super) fn hold_fullbright(&mut self) {
|
||||
let held = fullbright_held(
|
||||
self.remote.as_ref().map(|r| r.save_folder.is_some()),
|
||||
self.studio_replay.is_some(),
|
||||
self.replay.playing(),
|
||||
);
|
||||
self.settings.fullbright_held = held;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
|
||||
@@ -420,6 +420,7 @@ impl Game<'_> {
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.clone();
|
||||
self.falling_vertices.get_mut().clear();
|
||||
self.pack = pack;
|
||||
self.gui = gui;
|
||||
// The new atlas's skin and map slots are blank: players and maps
|
||||
|
||||
@@ -45,8 +45,9 @@ fn playing(test: impl FnOnce(&mut Game)) {
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
/// Hold `button` on the pad, as a poll that saw it go down would.
|
||||
fn pad_press(game: &mut Game, button: Button, action: &str) {
|
||||
/// Hold `button` on the pad, as a poll that saw it go down would: the press
|
||||
/// is recorded as `pad_button` records it, which is what `holds_action` reads.
|
||||
fn pad_press(game: &mut Game, button: Button, action: &'static str) {
|
||||
let mut state = PadState::default();
|
||||
// A trigger is held by how far it's pulled, not by a button bit.
|
||||
match button {
|
||||
@@ -55,6 +56,7 @@ fn pad_press(game: &mut Game, button: Button, action: &str) {
|
||||
_ => state.set(button, true),
|
||||
}
|
||||
game.pad.edges.update(&state, 0.4);
|
||||
game.pad.actions[button as usize] = vec![action];
|
||||
game.pad_action(action, true).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `PhantomSpawner.tick` tests the player's position with `Level.canSeeSky`,
|
||||
//! "sky light 15 here" (`BlockAndLightGetter.canSeeSky`), not a scan for a
|
||||
//! full collision cube above. Glass and stained glass are full collision cubes
|
||||
//! the sky light passes, so phantoms spawn under them; stone, water (which
|
||||
//! damps the light) and a bottom slab (whose up face occludes the sky-light
|
||||
//! column) do not. The world is lit by the vanilla light engine, not by hand.
|
||||
use super::super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::light::{LightTables, apply_lighting, light_chunk};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The prepared resources, or `None` where CI has none (the test then skips).
|
||||
fn resources() -> Option<std::path::PathBuf> {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
(root.join("occlusion.nbt").is_file() && root.join("entity-types.json").is_file())
|
||||
.then_some(root)
|
||||
}
|
||||
|
||||
/// A server's game on `root`: hard difficulty, a long-inhabited chunk, the
|
||||
/// player at (2.5, 99, 2.5), midnight (sky darken 11) and a fixed level seed.
|
||||
fn game(root: &std::path::Path) -> Game<'static> {
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.to_path_buf(),
|
||||
None,
|
||||
root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.to_path_buf();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
assert!(game.run_command("difficulty hard"));
|
||||
for x in -1..=0 {
|
||||
for z in -1..=0 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
// `getCurrentDifficultyAt`: an old chunk is harder, so every attempt
|
||||
// passes `isHarderThan(random * 3)` and the seed decides only the spawn.
|
||||
game.world.inhabited.insert((0, 0), 3_600_000);
|
||||
game.world.day_time = 18000;
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 99.0, 2.5)).unwrap());
|
||||
game.stats.custom("time_since_rest", i32::MAX);
|
||||
game.world.random.set_seed(0x5EED);
|
||||
game
|
||||
}
|
||||
|
||||
/// The sky light under a 5×5 roof of `roof` at y=100 (`None`: open sky), lit by
|
||||
/// `tables`, and the phantoms 64 spawn attempts make for the player under it.
|
||||
fn attempt(game: &mut Game<'_>, tables: &LightTables, roof: Option<&str>) -> (i32, Vec<Vec3>) {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
if let Some(roof) = roof {
|
||||
let state = game.pack.assets.registry.default_state(roof).unwrap().0;
|
||||
for x in 0..5 {
|
||||
for z in 0..5 {
|
||||
chunk.set(x, 100, z, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
let region = [
|
||||
[None, None, None],
|
||||
[None, Some(&chunk), None],
|
||||
[None, None, None],
|
||||
];
|
||||
let lights = light_chunk(®ion, tables, true);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.mobs.clear();
|
||||
let mut spawned = Vec::new();
|
||||
// `random.nextInt(timeSinceRest) >= 72000` almost always passes at
|
||||
// `i32::MAX`; 64 attempts leave no room for the draw to decide the test.
|
||||
for _ in 0..64 {
|
||||
game.stats.phantom_tick = 0;
|
||||
game.spawn_phantoms();
|
||||
spawned = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == horizon_client::mobs::Species::Phantom)
|
||||
.map(|m| m.position)
|
||||
.collect();
|
||||
if !spawned.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(i32::from(game.world.light(2, 99, 2) >> 4), spawned)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phantoms_spawn_under_a_glass_roof() {
|
||||
let Some(root) = resources() else { return };
|
||||
let mut game = game(&root);
|
||||
let tables = LightTables::load(&root, game.pack.assets.registry, game.res.facts()).unwrap();
|
||||
|
||||
// Open night sky.
|
||||
let (sky, phantoms) = attempt(&mut game, &tables, None);
|
||||
assert_eq!(sky, 15, "open sky light");
|
||||
assert!(!phantoms.is_empty(), "open sky spawns phantoms");
|
||||
|
||||
// Full collision cubes the sky light still passes.
|
||||
for roof in ["minecraft:glass", "minecraft:red_stained_glass"] {
|
||||
let (sky, phantoms) = attempt(&mut game, &tables, Some(roof));
|
||||
assert_eq!(sky, 15, "{roof} passes the sky light down");
|
||||
assert!(!phantoms.is_empty(), "phantoms spawn under {roof}");
|
||||
// `playerPos.above(20 + nextInt(15)).east(-10 + nextInt(21))...`
|
||||
for at in phantoms {
|
||||
assert!(
|
||||
(119.0..=133.0).contains(&at.y) && (at.x - 2.0).abs() <= 10.5,
|
||||
"{at:?} is not in the spawn box above the player"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_phantoms_under_a_roof_the_sky_light_does_not_reach() {
|
||||
let Some(root) = resources() else { return };
|
||||
let mut game = game(&root);
|
||||
let tables = LightTables::load(&root, game.pack.assets.registry, game.res.facts()).unwrap();
|
||||
|
||||
// Stone stops the light; water and ice damp it by a level; a bottom slab
|
||||
// is no full cube yet its up face occludes the sky-light column.
|
||||
for roof in [
|
||||
"minecraft:stone",
|
||||
"minecraft:water",
|
||||
"minecraft:ice",
|
||||
"minecraft:stone_slab",
|
||||
] {
|
||||
let (sky, phantoms) = attempt(&mut game, &tables, Some(roof));
|
||||
assert!(sky < 15, "{roof} keeps the sky light out, got {sky}");
|
||||
assert!(phantoms.is_empty(), "no phantom under {roof}");
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ impl Game<'_> {
|
||||
/// TNT duper's TNT stops at the block that has not moved yet (#11399).
|
||||
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
|
||||
let mut accumulators = std::collections::HashMap::new();
|
||||
let mut pricked = Vec::new();
|
||||
// Where each moving piston first sits in both lists, so a clock of a
|
||||
// thousand pistons doesn't scan them a thousand times a tick.
|
||||
let mut index = PistonIndex::default();
|
||||
@@ -92,9 +93,58 @@ impl Game<'_> {
|
||||
let targets = self.piston_push_targets(&pushes, &steps);
|
||||
for push in pushes {
|
||||
let pos = push.0;
|
||||
self.push_for_piston(push, &targets, &steps, &mut accumulators);
|
||||
self.push_for_piston(push, &targets, &steps, &mut accumulators, &mut pricked);
|
||||
self.advance_piston_collision(pos, &index);
|
||||
}
|
||||
self.hurt_cactus_pricked_vehicles(pricked);
|
||||
}
|
||||
|
||||
/// `moveEntityByPiston`'s `applyEffectsFromBlocks` for a boat or a
|
||||
/// minecart: every cactus its box touches on the way from `start` to
|
||||
/// `end` pricks it (`CactusBlock.entityInside`, `Entity.checkInsideBlocks`
|
||||
/// over the box less 1e-5), even when the push moved it nowhere. The hurt
|
||||
/// waits for the end of the tick's pushes, whose targets are found first.
|
||||
fn piston_cactus_contact(
|
||||
&self,
|
||||
target: Target,
|
||||
start: Aabb,
|
||||
end: Aabb,
|
||||
pricked: &mut Vec<u32>,
|
||||
) {
|
||||
let id = match target {
|
||||
Target::Entity(EntityHandle::Boat(i)) => self.boats[i].id,
|
||||
Target::Entity(EntityHandle::Cart(i)) => self.carts[i].id,
|
||||
_ => return,
|
||||
};
|
||||
let eps = f64::from(1.0e-5_f32);
|
||||
let (a, b) = (start.min_position(), end.min_position());
|
||||
let lo = Vec3::new(a.x.min(b.x), a.y.min(b.y), a.z.min(b.z));
|
||||
let (a, b) = (start.max_position(), end.max_position());
|
||||
let hi = Vec3::new(a.x.max(b.x), a.y.max(b.y), a.z.max(b.z));
|
||||
let facts = self.res.facts();
|
||||
for x in (lo.x + eps).floor() as i32..=(hi.x - eps).floor() as i32 {
|
||||
for y in (lo.y + eps).floor() as i32..=(hi.y - eps).floor() as i32 {
|
||||
for z in (lo.z + eps).floor() as i32..=(hi.z - eps).floor() as i32 {
|
||||
if &*facts.get(self.world.block(x, y, z)).name == "minecraft:cactus" {
|
||||
pricked.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.hurt(cactus, 1)` for the vehicles `piston_cactus_contact`
|
||||
/// found, in the order of their pushes (`VehicleEntity.hurtServer`: one
|
||||
/// more point of damage; a vehicle a piston pushes in a chunk that does
|
||||
/// not tick entities never wears it off, so the fifth breaks it).
|
||||
fn hurt_cactus_pricked_vehicles(&mut self, pricked: Vec<u32>) {
|
||||
for id in pricked {
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.hurt_boat(i, 1.0, false);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.hurt_cart(i, 1.0, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies any of this tick's moving blocks can reach, in
|
||||
@@ -373,6 +423,7 @@ impl Game<'_> {
|
||||
targets: &[Target],
|
||||
steps: &StepHeights,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
pricked: &mut Vec<u32>,
|
||||
) {
|
||||
let delta_progress = f64::from(new_progress - piston.progress);
|
||||
if delta_progress <= 0.0 {
|
||||
@@ -459,6 +510,7 @@ impl Game<'_> {
|
||||
step.scale(amount),
|
||||
movement,
|
||||
accumulators,
|
||||
pricked,
|
||||
);
|
||||
// `fixEntityWithinPistonBase`: a retracting source piston drags
|
||||
// an entity that is still inside its own block.
|
||||
@@ -482,6 +534,7 @@ impl Game<'_> {
|
||||
back.scale(fix),
|
||||
movement,
|
||||
accumulators,
|
||||
pricked,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -510,6 +563,7 @@ impl Game<'_> {
|
||||
piston_push::step_vector(movement).scale(delta_progress),
|
||||
movement,
|
||||
accumulators,
|
||||
pricked,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -669,6 +723,7 @@ impl Game<'_> {
|
||||
movement: Vec3,
|
||||
piston_direction: Direction,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
pricked: &mut Vec<u32>,
|
||||
) {
|
||||
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
|
||||
return;
|
||||
@@ -686,6 +741,7 @@ impl Game<'_> {
|
||||
self.projectile_piston_block_effects(i, at);
|
||||
}
|
||||
self.piston_block_triggers(before, before);
|
||||
self.piston_cactus_contact(target, before, before, pricked);
|
||||
self.release_after_block_move(target);
|
||||
return;
|
||||
}
|
||||
@@ -787,6 +843,9 @@ impl Game<'_> {
|
||||
if let Some((i, from, ..)) = tnt {
|
||||
self.finish_tnt_piston_move(i, from, resolved);
|
||||
}
|
||||
if let Target::Entity(EntityHandle::Falling(i)) = target {
|
||||
self.finish_falling_piston_move(i, resolved);
|
||||
}
|
||||
if let Some((i, from)) = projectile_from {
|
||||
self.projectile_piston_block_effects(i, from);
|
||||
}
|
||||
@@ -796,6 +855,7 @@ impl Game<'_> {
|
||||
before
|
||||
};
|
||||
self.piston_block_triggers(before, after);
|
||||
self.piston_cactus_contact(target, before, after, pricked);
|
||||
}
|
||||
|
||||
/// `Entity.restituteMovementAfterCollisions` after a block's push, for a
|
||||
@@ -1571,6 +1631,7 @@ mod h255_mob_piston_ground_tests {
|
||||
movement,
|
||||
Direction::East,
|
||||
&mut std::collections::HashMap::new(),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
}
|
||||
/// `AbstractArrow.move` after a push the collisions cut to nothing: a stuck
|
||||
@@ -1606,6 +1667,7 @@ mod h255_mob_piston_ground_tests {
|
||||
Vec3::new(0.3, 0.0, 0.0),
|
||||
Direction::East,
|
||||
&mut std::collections::HashMap::new(),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
let p = &game.creatures.projectiles[i];
|
||||
assert_eq!(p.position, before, "the stone stops the push");
|
||||
@@ -1643,6 +1705,7 @@ mod h255_mob_piston_ground_tests {
|
||||
movement,
|
||||
Direction::Down,
|
||||
budget,
|
||||
&mut Vec::new(),
|
||||
);
|
||||
};
|
||||
push(game, &mut budget, Vec3::ZERO);
|
||||
|
||||
@@ -58,6 +58,22 @@ impl Game<'_> {
|
||||
self.keys.remove(&key);
|
||||
}
|
||||
}
|
||||
pub(super) fn update_modifiers(&mut self, state: winit::keyboard::ModifiersState) {
|
||||
self.modifiers = state;
|
||||
horizon_client_input::modifiers::reconcile(
|
||||
self.modifiers,
|
||||
&mut self.physical_keys,
|
||||
&self.settings.keys,
|
||||
&mut self.toggles,
|
||||
[self.settings.toggle_crouch, self.settings.toggle_sprint],
|
||||
[
|
||||
self.pad.holds_action("key.sneak"),
|
||||
self.pad.holds_action("key.sprint"),
|
||||
],
|
||||
);
|
||||
self.sync_toggle(SNEAK);
|
||||
self.sync_toggle(SPRINT);
|
||||
}
|
||||
/// Let a held sneak, sprint or modifier key go when the keyboard reports
|
||||
/// its modifier up. A release does not always reach the mapping: a screen
|
||||
/// reads presses only, and an unfocused window is given no release at all,
|
||||
@@ -90,6 +106,16 @@ impl Game<'_> {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if !self.modifiers.alt_key() {
|
||||
for key in [KeyCode::AltLeft, KeyCode::AltRight] {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if !self.modifiers.super_key() {
|
||||
for key in [KeyCode::SuperLeft, KeyCode::SuperRight] {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
for (slot, mapping, needs_toggle) in [
|
||||
(0, "key.sneak", self.settings.toggle_crouch),
|
||||
(1, "key.sprint", self.settings.toggle_sprint),
|
||||
|
||||
@@ -111,12 +111,11 @@ impl Game<'_> {
|
||||
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
|
||||
equipped.push((self.offhand.as_ref(), "offhand"));
|
||||
for (stack, slot) in equipped {
|
||||
let Some(info) = stack.and_then(|s| world_items!(self).get(&s.item)) else {
|
||||
let Some(stack) = stack else {
|
||||
continue;
|
||||
};
|
||||
for (attribute, id, amount, op, group) in &info.modifiers {
|
||||
// The attack code still reads the held item's attack damage and
|
||||
// speed directly; the map copy is what `/attribute` reports.
|
||||
let modifiers = world_items!(self).attribute_modifiers(stack);
|
||||
for (attribute, id, amount, op, group) in &modifiers {
|
||||
if !equipment_modifier_applies(group, slot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user