mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 10:31:38 -10:00
Compare commits
532
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18ceb494e9 | ||
|
|
69ba5a7b63 | ||
|
|
2e2218eced | ||
|
|
f4e5eb5bcd | ||
|
|
49667c9f3b | ||
|
|
37050a4985 | ||
|
|
7d76c6bb80 | ||
|
|
a1cd2db8eb | ||
|
|
c2157fb2db | ||
|
|
fb03ea2f84 | ||
|
|
92705c55fe | ||
|
|
a85af177a8 | ||
|
|
663ed839ff | ||
|
|
41e2db604b | ||
|
|
7d5427bb4f | ||
|
|
cbc97b2529 | ||
|
|
86a8472693 | ||
|
|
b621a878d3 | ||
|
|
0d8f2ef270 | ||
|
|
e68a3c6974 | ||
|
|
5bf9ac1931 | ||
|
|
8b3db9eec1 | ||
|
|
9c1245513c | ||
|
|
4a3d5daf95 | ||
|
|
199c56fd1a | ||
|
|
b0211887f2 | ||
|
|
99f82f18a9 | ||
|
|
e5177563e9 | ||
|
|
531a071845 | ||
|
|
49cdc775d8 | ||
|
|
b66b2f8372 | ||
|
|
e14b35cecb | ||
|
|
4e71c65c5c | ||
|
|
9a8c769601 | ||
|
|
f8a833cc41 | ||
|
|
5a58f445a6 | ||
|
|
ddd66cd75f | ||
|
|
12315601fd | ||
|
|
fe0dc37392 | ||
|
|
8f3adbae40 | ||
|
|
678ed9eb24 | ||
|
|
61444edbef | ||
|
|
d38111c32a | ||
|
|
0e87353bd8 | ||
|
|
74039da8e1 | ||
|
|
5d71cad052 | ||
|
|
0bdf9ff9d8 | ||
|
|
e94ffb5b10 | ||
|
|
0065a8925a | ||
|
|
91f26d5aa2 | ||
|
|
d0706e0059 | ||
|
|
5c0a3b5ecf | ||
|
|
fb0127a07f | ||
|
|
9aa200334a | ||
|
|
7c0b174bbf | ||
|
|
b52cee2760 | ||
|
|
82d4e2faa9 | ||
|
|
fa6140c555 | ||
|
|
9945e0cb21 | ||
|
|
f55a82aa12 | ||
|
|
f72c7f644d | ||
|
|
15ed0df804 | ||
|
|
1b2c3b4ae2 | ||
|
|
9d87f01363 | ||
|
|
e10f3459c8 | ||
|
|
2b43458b4a | ||
|
|
9d97041ffb | ||
|
|
05e964cd8f | ||
|
|
5a56eb3ae1 | ||
|
|
38ce5cfa4e | ||
|
|
2be5079ae1 | ||
|
|
1d5957fb9d | ||
|
|
9c03f504a2 | ||
|
|
fc78a29fee | ||
|
|
d05a66c6f2 | ||
|
|
461c878b6a | ||
|
|
216aca09f6 | ||
|
|
ff11e449d3 | ||
|
|
31c5e22e7f | ||
|
|
687f3f6d3f | ||
|
|
6e5784e4fa | ||
|
|
e653778213 | ||
|
|
82134c2755 | ||
|
|
cd4e6696fa | ||
|
|
06560cdd7d | ||
|
|
1420b5bd29 | ||
|
|
cef4066d7e | ||
|
|
77e510c3ab | ||
|
|
20cd32f3a4 | ||
|
|
f0fb237cc2 | ||
|
|
eebd726751 | ||
|
|
3acf30529b | ||
|
|
197fa1e294 | ||
|
|
cd0b263747 | ||
|
|
1fce5ccdbf | ||
|
|
244b166781 | ||
|
|
f2b247edda | ||
|
|
7baaa34948 | ||
|
|
517889c893 | ||
|
|
95fe2c81f2 | ||
|
|
6d783a4116 | ||
|
|
9f32ef4233 | ||
|
|
f23176af0f | ||
|
|
909d7a2134 | ||
|
|
7ca851a78d | ||
|
|
8f27aaeb01 | ||
|
|
3e75c0f077 | ||
|
|
a64e9c8037 | ||
|
|
1bec6d447a | ||
|
|
d08c04020c | ||
|
|
d83b716d13 | ||
|
|
8ded4de3fe | ||
|
|
db69868ebb | ||
|
|
adb97c68d7 | ||
|
|
e0d880754e | ||
|
|
e415db6e71 | ||
|
|
c72e912d73 | ||
|
|
63f703ccb8 | ||
|
|
e559d499fb | ||
|
|
08095e81cb | ||
|
|
b880384691 | ||
|
|
5ee4540940 | ||
|
|
77a34310b0 | ||
|
|
7627df18b0 | ||
|
|
ee66246131 | ||
|
|
766a5d4c87 | ||
|
|
5add9e31c0 | ||
|
|
0779ef7099 | ||
|
|
f6850ed4a5 | ||
|
|
ec1ab4c1a9 | ||
|
|
ffd67ee750 | ||
|
|
ef207822ad | ||
|
|
45bfbc8a5f | ||
|
|
7145f30254 | ||
|
|
e43c0f75dc | ||
|
|
7d84ad3777 | ||
|
|
14bc8fbebe | ||
|
|
56a25648ac | ||
|
|
5323f98c42 | ||
|
|
70a011535b | ||
|
|
9b006dcab6 | ||
|
|
172965a9a0 | ||
|
|
8d47c940e6 | ||
|
|
0d92a3d997 | ||
|
|
7d6b37b8e6 | ||
|
|
04a26210e3 | ||
|
|
59a74c49ac | ||
|
|
a899c56c45 | ||
|
|
cff772e57a | ||
|
|
f5541f7c45 | ||
|
|
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 | ||
|
|
2160d3e4d1 | ||
|
|
4536ec51ca | ||
|
|
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 | ||
|
|
9ce725cca5 | ||
|
|
fb9b1d3542 | ||
|
|
4d4aec1c7a | ||
|
|
a8a5843f59 | ||
|
|
63a2feba79 | ||
|
|
b228e6bef7 | ||
|
|
6a246dd81d | ||
|
|
4c49ec3523 | ||
|
|
2e6f3cd716 | ||
|
|
9528d81b2f | ||
|
|
289e90d636 | ||
|
|
3275cd1602 | ||
|
|
a100957b30 | ||
|
|
3dcdbf8947 | ||
|
|
5da9065a8d | ||
|
|
a04de39fea | ||
|
|
76d95dbcc9 | ||
|
|
b9ad2da17e | ||
|
|
fab1e1a3e6 | ||
|
|
f6d8444698 | ||
|
|
b5a64cf86b | ||
|
|
0966aa0d3f | ||
|
|
9188926fff | ||
|
|
b920f8a432 | ||
|
|
16fb5cfed0 | ||
|
|
ef9f8e86d7 | ||
|
|
b91c2d4965 | ||
|
|
1cf4775de9 | ||
|
|
bdf670abdf | ||
|
|
8df65734b7 | ||
|
|
1b5800c3ab | ||
|
|
2a67bd988c | ||
|
|
66ead25e8d | ||
|
|
327b811914 | ||
|
|
938dcd673c | ||
|
|
e1848e08a4 | ||
|
|
3661bfd7c2 | ||
|
|
a08205b8ec | ||
|
|
41fdaa3f0c | ||
|
|
2398317072 | ||
|
|
669e1a162a | ||
|
|
d645b72e8b | ||
|
|
3bc4e3035c | ||
|
|
15183261d9 | ||
|
|
adbc523e5c | ||
|
|
7f5b3462df | ||
|
|
c106b315ad | ||
|
|
1d9040c05a | ||
|
|
a35b729ccf | ||
|
|
27cded80d5 | ||
|
|
64e74bffd2 | ||
|
|
f7c7e37999 | ||
|
|
db26868e4f | ||
|
|
5cfbc7e3ea | ||
|
|
3f1f2fd3a8 | ||
|
|
c8d9a748ac | ||
|
|
ab7d9f4c50 | ||
|
|
4f1b40d9ca | ||
|
|
81a8d04a1f | ||
|
|
e1a6c5e363 | ||
|
|
89733192ca | ||
|
|
b973a2ed28 | ||
|
|
03a6255967 | ||
|
|
6539061751 | ||
|
|
7059358371 | ||
|
|
aa25cc6cb3 | ||
|
|
d05c54b48e | ||
|
|
9f9d226294 | ||
|
|
0a9ceaedb2 | ||
|
|
d64e5172b6 | ||
|
|
faa8141c2f | ||
|
|
2d5e7839c6 | ||
|
|
c06d152243 | ||
|
|
7a47e7a21f | ||
|
|
188817342b | ||
|
|
56c7214447 | ||
|
|
c1d32a0f93 | ||
|
|
861914b486 | ||
|
|
d1ca042ab8 | ||
|
|
1e66c75589 | ||
|
|
7373b8cf73 | ||
|
|
68ad338029 | ||
|
|
bd6bf2422b | ||
|
|
f888d26173 | ||
|
|
72879fc35b | ||
|
|
38b2b41791 | ||
|
|
5f40c762f6 | ||
|
|
46988764a5 | ||
|
|
2289b7a732 | ||
|
|
c9cf9cb18b | ||
|
|
33754abe21 | ||
|
|
5a67941d3f | ||
|
|
d68f241a0e | ||
|
|
176f5b3475 | ||
|
|
34d39e0da0 | ||
|
|
a45aa615ef | ||
|
|
c7ca421167 | ||
|
|
c1540bd026 | ||
|
|
8683a6b570 | ||
|
|
d88c0d9011 | ||
|
|
62ed49d4a4 | ||
|
|
9f861b1624 | ||
|
|
bdf33dd2a3 | ||
|
|
016630efda | ||
|
|
3a953f4f9a | ||
|
|
91b743b549 | ||
|
|
59923087d5 | ||
|
|
763b6f6c8c | ||
|
|
88c287eb7e | ||
|
|
94f499bdbe | ||
|
|
f943e82857 | ||
|
|
6efab1f890 | ||
|
|
34b5ab04b0 | ||
|
|
a76ea00dde | ||
|
|
d7c7e06634 | ||
|
|
7f8a855d78 | ||
|
|
5f68b5da25 | ||
|
|
ca01155bce | ||
|
|
2740471e60 | ||
|
|
3f63aa1f5c | ||
|
|
a151569d72 | ||
|
|
bad4b0ef9a | ||
|
|
9482ae7472 | ||
|
|
3a22ba32c2 | ||
|
|
eedbb1717c | ||
|
|
fad8e4e555 | ||
|
|
dbbe0e6bf5 | ||
|
|
5a78eee6ca | ||
|
|
e11233f2dc | ||
|
|
941e45b1ba | ||
|
|
916064d1ac | ||
|
|
4ef09a8e0d | ||
|
|
d9cf8f497c | ||
|
|
a321aa6c73 | ||
|
|
2490c57d3e | ||
|
|
e624d71b21 | ||
|
|
a190dbbc0d | ||
|
|
f779b500ec | ||
|
|
4ed92f7176 | ||
|
|
6e8edf7ad2 | ||
|
|
deb5860d9e | ||
|
|
6dc2d97de4 | ||
|
|
b0074207ca | ||
|
|
920e317f53 | ||
|
|
9286b28e24 | ||
|
|
58abbd0e4a | ||
|
|
e6bc09eeb9 | ||
|
|
3ea0ce07a2 | ||
|
|
df4b2599b4 | ||
|
|
8aea9cb52b | ||
|
|
07a82eccf3 | ||
|
|
0bdc77efb6 | ||
|
|
3914d39e80 | ||
|
|
8aaa338f08 | ||
|
|
79b62aaaea | ||
|
|
02b3b03618 | ||
|
|
0acd52bcb5 | ||
|
|
02f00f9b83 | ||
|
|
7d75faab81 | ||
|
|
0eddebf75e | ||
|
|
baab01b031 | ||
|
|
034c4b6e5c | ||
|
|
566027307e | ||
|
|
e8dbc42da7 | ||
|
|
adfc70d2f3 | ||
|
|
9da6aa25e5 | ||
|
|
acf9008e49 | ||
|
|
7e32b7d0da | ||
|
|
c14ab8d543 | ||
|
|
cd9441f90e | ||
|
|
021bbb8225 | ||
|
|
4da0ea5197 | ||
|
|
ac296614af | ||
|
|
5328ec28b5 | ||
|
|
de6cc998df | ||
|
|
03b7d245b5 | ||
|
|
a6204f1f3a | ||
|
|
c2dd2d935d | ||
|
|
9f04ad5318 | ||
|
|
41d1c3adfe | ||
|
|
df7437d02b | ||
|
|
622eda982a | ||
|
|
cdc8772455 | ||
|
|
d289ee5de2 | ||
|
|
3a5bc7ef94 | ||
|
|
7725516a3e | ||
|
|
577c82ffd3 | ||
|
|
401373bc22 | ||
|
|
272fcfa34c | ||
|
|
1324281e8b | ||
|
|
997007191a | ||
|
|
2f1ad94bf7 | ||
|
|
a0f6aeafce | ||
|
|
2ec731aadf | ||
|
|
de08483605 | ||
|
|
9790d9d81c | ||
|
|
81b88d62be | ||
|
|
ace3563955 | ||
|
|
ae0ab270ff | ||
|
|
28214f02a7 | ||
|
|
5bb1789373 | ||
|
|
e865160f46 | ||
|
|
4c593f0ca7 | ||
|
|
ac90e00807 | ||
|
|
c7a360b9bb | ||
|
|
8f2b122754 | ||
|
|
cbd47615bc | ||
|
|
04822910b5 | ||
|
|
9e68efc05b | ||
|
|
b8ceaa3ed4 | ||
|
|
070902e9f5 | ||
|
|
6c4df147a7 | ||
|
|
3f044a9aca | ||
|
|
e749b17104 | ||
|
|
1340e54376 | ||
|
|
02a8a61de4 | ||
|
|
6a68afa6cc | ||
|
|
3c5c7d6077 | ||
|
|
d93a9a4be4 | ||
|
|
2b6aed3dc7 | ||
|
|
53d1d825ee | ||
|
|
6db95ffc1e | ||
|
|
5ae9f06129 | ||
|
|
13061c109f | ||
|
|
40eb5c7fd7 | ||
|
|
04a8acc5a3 | ||
|
|
add5b203c6 | ||
|
|
a93975ceb5 | ||
|
|
3ef15455e3 | ||
|
|
cef8682971 | ||
|
|
67d694dd9c | ||
|
|
7b25b8ed80 | ||
|
|
c2e4f28c3f | ||
|
|
8e26753cda | ||
|
|
ec08a90d43 | ||
|
|
edfe18495e | ||
|
|
8ede2ff197 | ||
|
|
f1696089ef | ||
|
|
a4925a7954 | ||
|
|
085b4109e3 | ||
|
|
4a6942b8e9 | ||
|
|
1efd1c20c9 | ||
|
|
d8c00ad64e | ||
|
|
e2f472508d | ||
|
|
38060a67ba | ||
|
|
6c199da5f6 | ||
|
|
c4a108b5c3 | ||
|
|
4628acd092 | ||
|
|
71cf748116 | ||
|
|
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
+12
-12
@@ -1,16 +1,16 @@
|
||||
# Changes to these files wait for Ludovic's review once branch protection requires code owners' reviews
|
||||
# Changes to these files wait for a council member's review (council poll P35, 2026-11-10) once branch protection requires code owners' reviews
|
||||
# (tools/ci/protect_pipeline.sh, docs/security.md). They are what builds, signs and ships releases, the code that
|
||||
# checks downloads before installing them (backend.rs downloads, installs and updates the launcher itself; links.json
|
||||
# says where from; the Cargo.toml files pick the crates that do it), and this file. Everything else merges as soon as
|
||||
# `verify` passes.
|
||||
/.github/ @ludovic111
|
||||
/tools/ci/ @ludovic111
|
||||
/tools/release_train.py @ludovic111
|
||||
/crates/horizon-release-sign/ @ludovic111
|
||||
/crates/horizon-server/src/update.rs @ludovic111
|
||||
/crates/horizon-server/Cargo.toml @ludovic111
|
||||
/launcher/src/signed.rs @ludovic111
|
||||
/launcher/src/backend.rs @ludovic111
|
||||
/launcher/links.json @ludovic111
|
||||
/launcher/Cargo.toml @ludovic111
|
||||
/packaging/ @ludovic111
|
||||
/.github/ @horizon-rust/council
|
||||
/tools/ci/ @horizon-rust/council
|
||||
/tools/release_train.py @horizon-rust/council
|
||||
/crates/horizon-release-sign/ @horizon-rust/council
|
||||
/crates/horizon-server/src/update.rs @horizon-rust/council
|
||||
/crates/horizon-server/Cargo.toml @horizon-rust/council
|
||||
/launcher/src/signed.rs @horizon-rust/council
|
||||
/launcher/src/backend.rs @horizon-rust/council
|
||||
/launcher/links.json @horizon-rust/council
|
||||
/launcher/Cargo.toml @horizon-rust/council
|
||||
/packaging/ @horizon-rust/council
|
||||
@@ -32,13 +32,6 @@
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
|
||||
|
||||
## Other projects' code
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
|
||||
the documentation pages and the packs you used. -->
|
||||
|
||||
## Self-check
|
||||
|
||||
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
|
||||
|
||||
+10
-360
@@ -40,7 +40,7 @@ jobs:
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -119,22 +119,22 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
cargo +stable clippy --workspace --all-targets --locked -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
cargo +stable clippy -p horizon-client --all-targets --locked --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# cargo test --locked, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# these 4-core runners it was no faster (#4978).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
cargo +stable test --locked --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
|
||||
# recompiling the workspace with a different dependency graph.
|
||||
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
cargo +stable test --locked --workspace --lib --bins --tests mod_content::tests:: -- --ignored
|
||||
cargo +stable test --locked --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -313,358 +313,8 @@ jobs:
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
|
||||
frame_identity: ${{ contains(github.event.pull_request.labels.*.name, 'frame-identity') }}
|
||||
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
|
||||
# (label horizon-game) holding the maintainer's own copy: the official launcher's
|
||||
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
|
||||
# HORIZON_SERVER_JAR. Start them by hand: gh workflow run checks --ref <branch>.
|
||||
vanilla-parity:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Compare Rust foundations with pinned vanilla client and server bytecode
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_rust_parity.py --prepare
|
||||
- name: Check Rust world-data interoperability with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_world_parity.py
|
||||
- name: Compare scheduled ticks and saved tick data with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_tick_parity.py
|
||||
- name: Compare simulation geometry and cached block collision shapes
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: |
|
||||
python tools/test_geometry_parity.py
|
||||
python tools/test_collision_parity.py
|
||||
- name: Compare noise samplers and density kernels with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_noise_parity.py
|
||||
- name: Compare compiled density samplers with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_density_parity.py
|
||||
- name: Compare world-generation JSON codecs with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_worldgen_parity.py
|
||||
- name: Compare heightmap predicates and chunk mutations with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_heightmap_parity.py
|
||||
- name: Compare pre-light chunk status transitions and block writes
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_chunk_stage_parity.py
|
||||
- name: Compare feature placement into resident neighboring chunks
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_region_parity.py
|
||||
- name: Compare nested feature selectors and sequences
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_graph_parity.py
|
||||
- name: Compare named feature holders and placed-feature tags
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_catalog_parity.py
|
||||
- name: Compare configured fill-layer block placement
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_fill_layer_parity.py
|
||||
- name: Compare replacement-rule codecs and evaluation
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_rule_test_parity.py
|
||||
- name: Compare simple-block placement and feature composition
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_simple_block_parity.py
|
||||
- name: Compare block-column sampling and obstruction
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_column_parity.py
|
||||
- name: Compare vertical pillars and cap feature placement
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_pillar_parity.py
|
||||
- name: Compare exactly-one-neighbor feature spreading
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_neighbor_spread_parity.py
|
||||
- name: Compare block-pile support and placement
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_pile_parity.py
|
||||
- name: Compare disk replacement and postprocessing
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_disk_parity.py
|
||||
- name: Compare block-blob support search and geometry
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_blob_parity.py
|
||||
- name: Compare spring placement and fluid tick scheduling
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_spring_parity.py
|
||||
- name: Compare vine attachment priority and placement
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_vines_parity.py
|
||||
- name: Compare underwater magma floor scans and occlusion
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_underwater_magma_parity.py
|
||||
- name: Compare blue-ice seeding and growth attempts
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_blue_ice_parity.py
|
||||
- name: Compare spike geometry and discontinuous roots
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_spike_parity.py
|
||||
- name: Compare End podium construction and destruction requests
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_end_podium_parity.py
|
||||
- name: Rust End gateway parity
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_end_gateway_parity.py
|
||||
- name: Compare bonus chest placement and conditional loot seeding
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_bonus_chest_parity.py
|
||||
- name: Compare recursive chorus generation and connection refreshes
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_chorus_plant_parity.py
|
||||
- name: Compare small speleothem patches and waterlogged points
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_speleothem_parity.py
|
||||
- name: Compare replacement blob search and Manhattan shells
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_replace_blobs_parity.py
|
||||
- name: Compare bamboo stalks, podzol patches and leafy caps
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_bamboo_parity.py
|
||||
- name: Compare Nether delta rims and shifted contents
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_delta_parity.py
|
||||
- name: Compare biome freezing and snow eligibility
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_weather_parity.py
|
||||
- name: Compare frozen top layers and snowy ground updates
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_snow_freeze_parity.py
|
||||
- name: Compare generation-stage tick storage and routing
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_generation_tick_parity.py
|
||||
- name: Compare below-zero upgrade metadata codecs
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_retrogen_data_parity.py
|
||||
- name: Compare below-zero upgrade lifecycle and block transformations
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_retrogen_chunk_parity.py
|
||||
- name: Compare chunk-status metadata, ordering and codecs
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_chunk_status_parity.py
|
||||
- name: Compare proto-chunk block-entity storage and coordinates
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_entity_storage_parity.py
|
||||
- name: Compare ordered single-block replacement features
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_replace_block_parity.py
|
||||
- name: Compare scattered ore placement and air-exposure checks
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_scattered_ore_parity.py
|
||||
- name: Compare resident bulk-section access and raw writes
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_bulk_section_parity.py
|
||||
- name: Compare biome feature ordering and cycle detection
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_sort_parity.py
|
||||
- name: Compare world-generation random wrappers and seeding
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_worldgen_random_parity.py
|
||||
- name: Compare biome filtering and top-level feature context
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_placement_parity.py
|
||||
- name: Compare biome decoration feature scheduling and seeding
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_decoration_parity.py
|
||||
- name: Compare decoration against resident biome and terrain sections
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_decoration_region_parity.py
|
||||
- name: Compare block-state provider choices and rotation
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_state_provider_parity.py
|
||||
- name: Compare contained fluids and height queries with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_fluid_parity.py
|
||||
- name: Compare entity obstruction with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_entity_obstruction_parity.py
|
||||
- name: Compare block solidity and replaceability with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_properties_parity.py
|
||||
- name: Compare face slicing and block support with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_face_support_parity.py
|
||||
- name: Compare cached block support with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_support_parity.py
|
||||
- name: Compare dynamic block support with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_dynamic_support_parity.py
|
||||
- name: Compare moving-piston collision and interpolation with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_piston_shape_parity.py
|
||||
- name: Compare supported block survival rules with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_survival_parity.py
|
||||
- name: Compare noise-stage chunk filling with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_terrain_parity.py
|
||||
- name: Compare structure terrain-adaptation density with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_structure_density_parity.py
|
||||
- name: Compare seeded material bands and surface queries with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_surface_parity.py
|
||||
- name: Compare material condition codecs and rule evaluation with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_condition_parity.py
|
||||
- name: Compare biome temperatures and cache behavior with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_temperature_parity.py
|
||||
- name: Compare badlands and frozen-ocean extensions with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_extension_parity.py
|
||||
- name: Compare bundled material-rule libraries with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_rule_library_parity.py
|
||||
- name: Compare fixed, checkerboard and End biome sources with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_source_parity.py
|
||||
- name: Compare seeded cave paths and failure state with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_cave_carver_parity.py
|
||||
- name: Compare seeded canyon paths and width factors with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_canyon_carver_parity.py
|
||||
- name: Compare initial block-writing feature algorithms with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_basic_feature_parity.py
|
||||
- name: Compare feature placement chains with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_feature_placement_parity.py
|
||||
- name: Compare feature placement primitives with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_placement_parity.py
|
||||
- name: Compare anchored height providers with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_height_provider_parity.py
|
||||
- name: Compare scalar value providers with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_value_provider_parity.py
|
||||
- name: Compare cave and canyon geometric primitives with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_carving_geometry_parity.py
|
||||
- name: Compare carving-mask traversal and callback mutations with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_carving_mask_parity.py
|
||||
- name: Compare registry-bound biome-source codecs with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_source_codec_parity.py
|
||||
- name: Compare all four biome-source kinds in chunk generation with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_generation_parity.py
|
||||
- name: Compare chunk biome filling and failure state with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_chunk_parity.py
|
||||
- name: Compare biome zoom and seed obfuscation with pinned vanilla
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_biome_zoom_parity.py
|
||||
- name: Compare surface-stage chunks with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_material_stage_parity.py
|
||||
- name: Compare block-state JSON codecs with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_block_state_codec_parity.py
|
||||
- name: Compare vertical anchors with pinned vanilla client and server
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_anchor_parity.py
|
||||
- name: Compare float logarithms at domain and rounding boundaries
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: stable
|
||||
run: python tools/test_float_log_parity.py
|
||||
# JFR analysis uses JDK-generated fixture recordings on the self-hosted Linux runner.
|
||||
# Start a manual checks dispatch with: gh workflow run checks --ref <branch>.
|
||||
jfr-analysis:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
|
||||
@@ -58,6 +58,18 @@ jobs:
|
||||
echo "Merges since ${BASE_SHA}:"
|
||||
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
|
||||
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
|
||||
- name: Preserve the initial failure logs
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
run: |
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
- name: Upload the initial failure logs
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
- name: A flake, or the merge that broke them
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
env:
|
||||
@@ -65,13 +77,4 @@ jobs:
|
||||
PARITY_HOSTS: ''
|
||||
BASE_SHA: ${{ steps.base.outputs.sha }}
|
||||
run: |
|
||||
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -1,7 +1,9 @@
|
||||
# Horizon: a map for agents
|
||||
|
||||
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
|
||||
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
|
||||
feature; performance work may differ inside as long as it stays compatible with vanilla
|
||||
([CONTRIBUTING.md](CONTRIBUTING.md#when-it-doesnt-have-to-be-11)). Read [CONTRIBUTING.md](CONTRIBUTING.md) once;
|
||||
this page tells you where things are.
|
||||
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Rules that bite
|
||||
@@ -20,6 +22,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
|
||||
@@ -35,7 +44,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
|
||||
@@ -47,6 +55,29 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
|
||||
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
|
||||
|
||||
## Don't lose time
|
||||
|
||||
Where agent time went, measured on sessions and CI in November 2026: reading code, compiling `horizon-client`,
|
||||
waiting in loops, and work someone else had already done. Tests were a small part.
|
||||
|
||||
- **Check nobody else is on it.** One in three pull requests closed unmerged was a duplicate. Before you write
|
||||
code, look at the issue's linked pull requests and run
|
||||
`gh pr list --state open --search "<issue number, function or file name>"`; then `/claim`.
|
||||
- **Find code with the map, not by reading.** Start from the tables below and
|
||||
`python3 tools/port_ledger.py where Class.method`. Read the function you change and its callers, not whole
|
||||
files.
|
||||
- **Compile cheaply while you work.** `cargo check -p <crate>` after an edit. Clippy, fmt and tests run once, at
|
||||
the end, through `pr_ready.py`. `cargo clippy -p horizon-client` and `cargo build -p horizon-client` are the
|
||||
slowest commands here: never after every edit, and build the game only when you need to run it.
|
||||
- **Don't wait in loops.** Start a long command in the background and carry on, or wait for it once. No
|
||||
`sleep` or `until grep` polling of builds, harnesses or CI.
|
||||
- **One test per changed behaviour.** None for a refactor or a file move, none that reads source text, and no
|
||||
harness where vanilla has nothing to compare (Horizon's own features, performance work).
|
||||
- **A red check isn't always yours.** When `verify` fails in a test your change can't reach, say so in the pull
|
||||
request and move on; don't hunt it.
|
||||
- **Unsure about a rule? Finish the work.** Take the reading that lets you finish, and say in the pull request
|
||||
which one you took. Don't stop, and don't change direction halfway.
|
||||
|
||||
## Crates
|
||||
|
||||
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
|
||||
@@ -96,6 +127,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
- Use independent expected values or a vanilla oracle; do not derive the answer from the code under test.
|
||||
- Test outcomes, not only calls to stubs. Use mocks to isolate behavior, not as the behavior itself.
|
||||
- Skip tests of trivial constants, derives and getters, or behavior already covered by another test.
|
||||
- One test per changed behaviour is enough. A refactor or a move that changes no behaviour gets no new test.
|
||||
- Avoid pinning implementation details; a harmless refactor should not break a behavior test.
|
||||
- Make slow tests faster without removing cases or weakening their coverage.
|
||||
- Never delete parity harnesses or tests that pin vanilla behaviour: they are the spec.
|
||||
@@ -152,7 +184,8 @@ python3 tools/lang_coverage.py --summary # the keys each language still misses
|
||||
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
|
||||
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
|
||||
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
|
||||
random draws must happen in vanilla's order.
|
||||
random draws must happen in vanilla's order. Performance work may break that order where no player can rely
|
||||
on it; the pull request says so ([CONTRIBUTING.md](CONTRIBUTING.md#when-it-doesnt-have-to-be-11)).
|
||||
|
||||
## horizon-client: where things live
|
||||
|
||||
@@ -215,7 +248,8 @@ player does (#1514). A window's `Game` never ticks a world itself: the old in-fr
|
||||
Open `[server-thread]` issues list the gaps left.
|
||||
|
||||
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
|
||||
Test the server thread when you touch player-facing gameplay:
|
||||
When you change how the server and its clients talk (packets, tracking, guests, the server thread itself), check
|
||||
it there. Other gameplay fixes are covered by their own harness and CI's parity job:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
|
||||
+26
-34
@@ -1,7 +1,8 @@
|
||||
# Contributing to Horizon
|
||||
|
||||
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
|
||||
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
|
||||
everything is that **it plays like vanilla**: first exactly like it, then better where players gain from it
|
||||
([vanilla first, then better](#vanilla-first-then-better)). This page is the short version of how we work; questions are
|
||||
welcome on [Discord](https://discord.gg/playhorizon).
|
||||
|
||||
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
|
||||
@@ -23,7 +24,7 @@ here.
|
||||
|
||||
## Build it
|
||||
|
||||
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
|
||||
- **Prerequisites:** Rust (stable), Python 3.11+, JDK 25, git 2.38 or newer, and on Linux the ALSA and udev headers
|
||||
(`libasound2-dev libudev-dev`).
|
||||
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
|
||||
described in [Build from source](README.md#build-from-source).
|
||||
@@ -36,7 +37,10 @@ here.
|
||||
|
||||
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
|
||||
|
||||
## Vanilla first
|
||||
## Vanilla first, then better
|
||||
|
||||
Horizon gets there in two stages. First it plays exactly like vanilla; that is what parity work is for. Then it
|
||||
improves on vanilla where players gain from it, mostly speed: FPS, TPS, loading, memory.
|
||||
|
||||
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
|
||||
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
|
||||
@@ -49,8 +53,10 @@ and compares their outputs.
|
||||
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
|
||||
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
|
||||
(ViaVersion, ViaProxy) in front of them.
|
||||
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
|
||||
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
|
||||
- **Ports of vanilla behaviour come with a harness**, new or extended, so the next change can't silently break
|
||||
them. [The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one. Where vanilla
|
||||
has nothing to compare (Horizon's own features, performance work, refactors), no harness is needed: one Rust
|
||||
test of the changed behaviour, or the numbers before and after.
|
||||
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
|
||||
off by default wherever they would change the vanilla look.
|
||||
- **Three layers: engine, platform, Horizon.**
|
||||
@@ -67,40 +73,26 @@ and compares their outputs.
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code, or code whose license allows it
|
||||
### When it doesn't have to be 1:1
|
||||
|
||||
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
|
||||
another project's source may also be read and used, **only when that project's license allows it**:
|
||||
Parity is about what players and the vanilla game see, not about copying vanilla's internals. Performance work (a
|
||||
multithreaded server tick, rendering, chunk loading, memory) doesn't have to keep vanilla's internal order or its
|
||||
exact random calls. It has to work together with normal Minecraft:
|
||||
|
||||
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
|
||||
public-domain dedication. That code may be read, copied or adapted.
|
||||
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
|
||||
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
|
||||
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
|
||||
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
|
||||
to download a mod or a pack does not give you the right to read its files.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
|
||||
your commit counts. In doubt, don't: ask in #contributors first.
|
||||
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
|
||||
pull request that used another project's source without saying so is closed.
|
||||
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
|
||||
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
|
||||
was changed.
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
|
||||
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
|
||||
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
|
||||
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
|
||||
names its sources: the sections of that specification, the documentation pages and the packs it ran.
|
||||
- **Vanilla clients and servers still work with it.** A vanilla 26.3 client plays on a Horizon server, Horizon joins
|
||||
vanilla servers, and saves and worlds open in both, as before.
|
||||
- **The game still plays the same.** Farms, redstone, mobs and drops work the way players know from vanilla. A
|
||||
difference may only show where no player can rely on it, such as which of two mobs ticks first or exactly which
|
||||
random number a mob gets.
|
||||
- **Vanilla's slow paths aren't copied.** When vanilla spends a lot of FPS or TPS on something that can reach the
|
||||
same result faster, Horizon does it faster. Copying a performance problem isn't parity.
|
||||
- **Say what changes.** The pull request names the vanilla behaviour it no longer reproduces exactly, why players
|
||||
won't notice it or why it's worth it, and how it was checked: numbers before and after, and the harnesses that
|
||||
still pass. A change players could notice, such as a different tick order on a busy server, goes behind a setting.
|
||||
|
||||
## Vendor libraries and Java mods
|
||||
|
||||
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
|
||||
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository.
|
||||
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
|
||||
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
|
||||
repository.
|
||||
|
||||
Generated
+1
@@ -1947,6 +1947,7 @@ name = "horizon-server-files"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
|
||||
+1
-1
@@ -530,7 +530,7 @@ unable to act, and never puts an account that is tied to a person into anyone el
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **GitHub organisation** (horizon-rust) | Two owners since 2026-11-11: the founder and rdbtCVS (P28); a `council` team holds CODEOWNERS (P35) and is the security managers (P36) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
|
||||
@@ -109,7 +109,7 @@ horizon-ctl break -1 65 -3 # mines it; replies with
|
||||
|
||||
## Build from source
|
||||
|
||||
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
Needs Rust 1.90+, Python 3.11+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
|
||||
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
|
||||
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -23,6 +23,10 @@ pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
/// Horizon's website, where the launcher is downloaded: the game started without its prepared
|
||||
/// files sends the player there.
|
||||
pub const WEBSITE: &str = "https://playhorizon.dev";
|
||||
|
||||
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
|
||||
/// (`HORIZON_COSMETICS_API` overrides it).
|
||||
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
|
||||
|
||||
@@ -21,7 +21,6 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
pub mod prediction;
|
||||
pub mod timer;
|
||||
|
||||
/// What one check's violation level leads to.
|
||||
@@ -162,8 +161,8 @@ checks! {
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
|
||||
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// (horizon-client's `lan::prediction::record`), with a ping a tick to place what the server changes,
|
||||
/// and played again (`lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
|
||||
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
|
||||
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
|
||||
@@ -279,15 +278,6 @@ pub struct Player {
|
||||
pub timer: timer::Balance,
|
||||
/// The server tick and height where the ground last held the player.
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -320,7 +320,12 @@ pub fn java_hash_order(
|
||||
"FallFlying",
|
||||
"sleeping_pos",
|
||||
"Brain",
|
||||
"last_hurt_by_player",
|
||||
"last_hurt_by_player_memory_time",
|
||||
"last_hurt_by_mob",
|
||||
"ticks_since_last_hurt_by_mob",
|
||||
"equipment",
|
||||
"locator_bar_icon",
|
||||
// ArmorStand.
|
||||
"Invisible",
|
||||
"Small",
|
||||
@@ -337,6 +342,12 @@ pub fn java_hash_order(
|
||||
"immovable",
|
||||
"description",
|
||||
"hide_description",
|
||||
// Interaction.addAdditionalSaveData.
|
||||
"width",
|
||||
"height",
|
||||
"attack",
|
||||
"interaction",
|
||||
"response",
|
||||
];
|
||||
// BlockEntity.saveMetadata writes the position after the entity's data.
|
||||
const AFTER: &[&str] = &["x", "y", "z"];
|
||||
@@ -436,6 +447,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn interaction_compound_orders_width_before_interaction_in_shared_bucket() {
|
||||
// `width` and `interaction` share a bucket at every table size;
|
||||
// `Interaction.addAdditionalSaveData` writes `width` first.
|
||||
let tag = parse_snbt(
|
||||
r#"{Air:300s,Fire:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[4.5d,100.0d,4.5d],Rotation:[0.0f,0.0f],UUID:[I;66,0,0,1],fall_distance:0.0d,height:1.25f,interaction:{player:[I;1,2,3,4],timestamp:9L},response:0b,width:1.0f}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(
|
||||
pretty(&tag).plain(),
|
||||
"{Motion: [0.0d, 0.0d, 0.0d], Invulnerable: 0b, fall_distance: 0.0d, Air: 300s, OnGround: 0b, PortalCooldown: 0, Rotation: [0.0f, 0.0f], Pos: [4.5d, 100.0d, 4.5d], response: 0b, Fire: 0s, width: 1.0f, interaction: {player: [I; 1, 2, 3, 4], timestamp: 9L}, UUID: [I; 66, 0, 0, 1], height: 1.25f}",
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compact_form_matches_vanilla() {
|
||||
assert_eq!(
|
||||
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -439,20 +439,33 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The report for a hung server tick, written by the watchdog thread
|
||||
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
|
||||
/// the report carries none.
|
||||
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
|
||||
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
|
||||
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
|
||||
/// vanilla's report carries the server thread's stack; without it, the details say why.
|
||||
pub fn report_watchdog(
|
||||
home: &Path,
|
||||
side: &'static str,
|
||||
message: String,
|
||||
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
|
||||
) -> Option<PathBuf> {
|
||||
let mut details = system_details(side);
|
||||
let stack = match server_stack {
|
||||
Ok(stack) => stack_lines(&stack, false),
|
||||
Err(why) => {
|
||||
details.push(("Server thread stack".into(), why.to_string()));
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
deliver(
|
||||
home,
|
||||
Report {
|
||||
time: Local::now(),
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
stack,
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
details,
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1496,7 +1496,7 @@ pub struct Renderer {
|
||||
shadow_seen_scratch: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Shadow translucent section draws, retained between frames.
|
||||
shadow_translucent_scratch: Vec<(u64, [u32; 5], (u32, Option<u32>))>,
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::game_terrain`).
|
||||
pack_lava_layers: Vec<u16>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
@@ -7540,10 +7540,6 @@ impl Renderer {
|
||||
}
|
||||
});
|
||||
horizon_shaders::GameInputs {
|
||||
terrain_parts: terrain
|
||||
.as_ref()
|
||||
.map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)),
|
||||
terrain,
|
||||
terrain_geometry,
|
||||
entity_geometry: Some(pack_entities::geometry_inputs()),
|
||||
sky: Some(pack_sky::inputs()),
|
||||
@@ -7553,6 +7549,7 @@ impl Renderer {
|
||||
weather: Some(pack_weather::inputs()),
|
||||
lines: Some(pack_lines::inputs()),
|
||||
hand: Some(pack_hand::inputs()),
|
||||
..pack_atlas::game_terrain(terrain, &self.pack_lava_layers)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -666,34 +666,52 @@ const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
|
||||
}\n \
|
||||
}\n";
|
||||
|
||||
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
|
||||
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
|
||||
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
|
||||
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
|
||||
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
|
||||
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
|
||||
/// GLSL: whether a terrain vertex's quad is solid terrain rather than cutout. Solid: the quads
|
||||
/// over opaque texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks
|
||||
/// `ChunkSectionLayer.SOLID` per quad (`FaceBakery.computeMaterialTransparency`,
|
||||
/// `SectionCompiler.compile`), and lava's faces (sprite layers `lava`, its still and flowing
|
||||
/// sprites), which Iris 1.11.7 drew with `gbuffers_terrain_solid` in our probes, with and
|
||||
/// without `separateAo`.
|
||||
///
|
||||
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
|
||||
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
|
||||
/// holds each vertex's own ambient occlusion.
|
||||
pub fn terrain_parts(
|
||||
terrain: &horizon_shaders::GbuffersInputs,
|
||||
lava: &[u16],
|
||||
) -> [horizon_shaders::GbuffersInputs; 2] {
|
||||
fn solid(lava: &[u16]) -> String {
|
||||
let opaque = crate::mesher::OPAQUE_LAYER;
|
||||
let lava: String = lava
|
||||
.iter()
|
||||
.map(|l| format!(" || hz_v_layer == {l}u"))
|
||||
.collect();
|
||||
let part = |solid: bool| {
|
||||
format!("(hz_v_layer & {opaque}u) != 0u{lava}")
|
||||
}
|
||||
|
||||
/// The game's `terrain` inputs (`PackAtlas::terrain_inputs`) for a pack: whole; drawing only
|
||||
/// solid terrain, then only cutout terrain (`solid`), each leaving the other part's quads outside
|
||||
/// the clip volume; and drawing both in one pass, each quad with its part's alpha test.
|
||||
pub fn game_terrain(
|
||||
terrain: Option<horizon_shaders::GbuffersInputs>,
|
||||
lava: &[u16],
|
||||
) -> horizon_shaders::GameInputs {
|
||||
let solid = solid(lava);
|
||||
let part = |terrain: &horizon_shaders::GbuffersInputs, part: bool| {
|
||||
let mut inputs = terrain.clone();
|
||||
inputs.after.push_str(&format!(
|
||||
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
|
||||
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
|
||||
" if (({solid}) != {part}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
|
||||
));
|
||||
inputs
|
||||
};
|
||||
[part(true), part(false)]
|
||||
horizon_shaders::GameInputs {
|
||||
terrain_parts: terrain.as_ref().map(|t| [part(t, true), part(t, false)]),
|
||||
terrain_one_pass: terrain.as_ref().map(|t| horizon_shaders::GbuffersInputs {
|
||||
parts: Some(horizon_shaders::Parts {
|
||||
solid: solid.clone(),
|
||||
cutout_alpha_test: None,
|
||||
}),
|
||||
..t.clone()
|
||||
}),
|
||||
terrain,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
@@ -852,6 +870,7 @@ impl PackAtlas {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -125,6 +125,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
|
||||
@@ -192,6 +193,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after,
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -2964,17 +2964,13 @@ impl PackPost {
|
||||
p.name
|
||||
));
|
||||
}
|
||||
match (&terrain, &mut terrain_cutout) {
|
||||
match (&terrain, &terrain_cutout) {
|
||||
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
|
||||
(t, Some(c)) => {
|
||||
if let Some(t) = t {
|
||||
notes.push(format!("solid terrain draws with {}", t.name));
|
||||
}
|
||||
notes.push(format!("cutout terrain draws with {}", c.name));
|
||||
// Timed (and labelled) apart from the solid part when it runs the same program.
|
||||
if t.as_ref().is_some_and(|t| t.name == c.name) {
|
||||
c.name.push_str(" (cutout)");
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
@@ -3911,8 +3907,8 @@ impl PackPost {
|
||||
}
|
||||
|
||||
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
|
||||
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
|
||||
/// same draws.
|
||||
/// the game's draws. Solid terrain in two parts (a pack's `gbuffers_terrain_solid` and
|
||||
/// `_cutout`) has its cutout part drawn second, from the same draws.
|
||||
fn draw_terrain(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
|
||||
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,parts:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
@@ -717,6 +717,7 @@ fn shadow_depth_map_reads(
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -48,6 +48,7 @@ fn pack(outputs: u32) -> Post {
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -44,6 +44,7 @@ fn pack() -> Post {
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -89,14 +89,7 @@ fn strips_of(
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
|
||||
&terrain,
|
||||
&[LAVA, LAVA + 1],
|
||||
)),
|
||||
terrain: Some(terrain),
|
||||
..Default::default()
|
||||
};
|
||||
let inputs = super::super::pack_atlas::game_terrain(Some(terrain), &[LAVA, LAVA + 1]);
|
||||
const ATTRS: [wgpu::VertexAttribute; 3] =
|
||||
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
@@ -346,3 +339,64 @@ fn alpha_test_ref_is_0_for_solid_terrain_and_0_5_for_cutout() {
|
||||
assert_eq!(got, [Some(RED), Some(YELLOW), Some(RED)], "{names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `alphaTest.gbuffers_terrain` replaces both parts' tests when one program draws them, as it
|
||||
/// did when they drew in two passes: over `GREATER 0.25`, alpha 0.3 keeps cutout terrain too
|
||||
/// and 0.2 loses solid terrain too, and `alphaTestRef` reads 0.25 in both.
|
||||
#[test]
|
||||
fn a_packs_terrain_alpha_test_applies_to_both_parts() {
|
||||
let properties = || {
|
||||
(
|
||||
"shaders.properties".to_owned(),
|
||||
"alphaTest.gbuffers_terrain=GREATER 0.25\n".to_owned(),
|
||||
)
|
||||
};
|
||||
for (alpha, kept) in [(0.3, true), (0.2, false)] {
|
||||
let mut files = program("gbuffers_terrain", RED, alpha).to_vec();
|
||||
files.push(properties());
|
||||
let Some((got, _)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [kept.then_some(RED); 3], "alpha {alpha}");
|
||||
}
|
||||
let files = vec![
|
||||
(
|
||||
"gbuffers_terrain.vsh".to_owned(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".to_owned(),
|
||||
),
|
||||
(
|
||||
"gbuffers_terrain.fsh".to_owned(),
|
||||
"#version 120\nuniform float alphaTestRef;\n/* DRAWBUFFERS:0 */\n\
|
||||
void main(){gl_FragData[0]=vec4(1.0,alphaTestRef*4.0,0.0,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
properties(),
|
||||
];
|
||||
let Some((got, _)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some([255, 255, 0]); 3]);
|
||||
}
|
||||
|
||||
/// The vertex stage reads its own quad's `alphaTestRef` too: 0 for solid, 0.5 for cutout.
|
||||
#[test]
|
||||
fn the_vertex_stage_reads_its_parts_alpha_test_ref() {
|
||||
let files = vec![
|
||||
(
|
||||
"gbuffers_terrain.vsh".to_owned(),
|
||||
"#version 120\nuniform float alphaTestRef;\nvarying float ref;\n\
|
||||
void main(){ref=alphaTestRef;gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"gbuffers_terrain.fsh".to_owned(),
|
||||
"#version 120\nvarying float ref;\n/* DRAWBUFFERS:0 */\n\
|
||||
void main(){gl_FragData[0]=vec4(1.0,ref*2.0,0.0,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
let Some((got, _)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some(RED), Some([255, 255, 0]), Some(RED)]);
|
||||
}
|
||||
@@ -102,6 +102,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
// depth. Its fragments read their own depth, as in Iris.
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
@@ -132,6 +133,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -44,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
parts: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
|
||||
@@ -1897,10 +1897,12 @@ mod tests {
|
||||
let g = Graphics::default();
|
||||
assert_eq!(g.workers, 0);
|
||||
let cores = horizon_threads::worker_count();
|
||||
// Vanilla's bound: one core is always left for the render and server threads.
|
||||
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
|
||||
if !cfg!(target_vendor = "apple") {
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
}
|
||||
// The count itself is `horizon_threads::default_workers`, which is one less than
|
||||
// the *smaller* of the machine's cores and its performance cores (a Linux hybrid
|
||||
// CPU reports fewer of the latter), so it is not always `cores - 1`.
|
||||
assert_eq!(g.worker_threads(), horizon_threads::default_workers());
|
||||
// A manual count is the player's own choice, kept as asked.
|
||||
let manual = Graphics { workers: 3, ..g };
|
||||
assert_eq!(manual.worker_threads(), 3);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ use crate::{
|
||||
|
||||
mod shell_pages;
|
||||
|
||||
#[path = "options_ui_wheel.rs"]
|
||||
mod wheel;
|
||||
pub(crate) use wheel::slider_unsnapped;
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
@@ -365,7 +369,7 @@ impl Opt {
|
||||
),
|
||||
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
|
||||
Opt::Sensitivity => Kind::Slider(s.sensitivity),
|
||||
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
|
||||
Opt::WheelSensitivity => wheel::kind(s.mouse_wheel_sensitivity),
|
||||
Opt::Volume(i) => Kind::Slider(if i == 0 {
|
||||
s.master_volume
|
||||
} else {
|
||||
@@ -916,7 +920,7 @@ impl Opt {
|
||||
}
|
||||
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
|
||||
Opt::Sensitivity => s.sensitivity = v,
|
||||
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
|
||||
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = wheel::from_slider(v),
|
||||
Opt::Volume(0) => s.master_volume = v,
|
||||
Opt::Volume(i) => s.volumes[i] = v,
|
||||
Opt::Gamma => s.gamma = v,
|
||||
@@ -1604,23 +1608,6 @@ pub struct MusicGroup {
|
||||
/// The rows, in `sounds.json` order.
|
||||
pub tracks: Vec<MusicTrack>,
|
||||
}
|
||||
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
|
||||
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
|
||||
fn delays_apply(opt: Opt) -> bool {
|
||||
matches!(
|
||||
opt,
|
||||
Opt::RenderDistance | Opt::SimulationDistance | Opt::BiomeBlend | Opt::WheelSensitivity
|
||||
)
|
||||
}
|
||||
/// Whether a slider still shows its own position: while held, or for a
|
||||
/// delayed one until its value applies (both then `resetValue`).
|
||||
fn slider_unsnapped(opt: Opt, held: bool, moved: Instant) -> bool {
|
||||
if delays_apply(opt) {
|
||||
moved.elapsed().as_millis() < 600
|
||||
} else {
|
||||
held
|
||||
}
|
||||
}
|
||||
pub struct OptionsUi {
|
||||
pub stack: Vec<Page>,
|
||||
scroll: f32,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `Options.mouseWheelSensitivity`'s logarithmic slider (`logMouse`/`unlogMouse`)
|
||||
//! and the delayed-apply test the slider buttons share.
|
||||
use super::*;
|
||||
use std::time::Instant;
|
||||
|
||||
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
|
||||
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
|
||||
fn delays_apply(opt: Opt) -> bool {
|
||||
matches!(
|
||||
opt,
|
||||
Opt::RenderDistance | Opt::SimulationDistance | Opt::BiomeBlend | Opt::WheelSensitivity
|
||||
)
|
||||
}
|
||||
/// Whether a slider still shows its own position: while held, or for a
|
||||
/// delayed one until its value applies (both then `resetValue`).
|
||||
pub(crate) fn slider_unsnapped(opt: Opt, held: bool, moved: Instant) -> bool {
|
||||
if delays_apply(opt) {
|
||||
moved.elapsed().as_millis() < 600
|
||||
} else {
|
||||
held
|
||||
}
|
||||
}
|
||||
/// `Options.unlogMouse`: the stored sensitivity as its hundredth-log step,
|
||||
/// -200..=100, which the `IntRange(-200, 100)` slider runs over (0.01 to 10.0).
|
||||
pub(crate) fn step(value: f32) -> f32 {
|
||||
(value.max(1.0e-6).log10() * 100.0)
|
||||
.floor()
|
||||
.clamp(-200.0, 100.0)
|
||||
}
|
||||
/// `IntRange(-200, 100).toSliderValue`: the step over the slider's span.
|
||||
pub(crate) fn kind(value: f32) -> Kind {
|
||||
Kind::Slider(((step(value) + 200.0) / 300.0).clamp(0.0, 1.0))
|
||||
}
|
||||
/// `IntRange(-200, 100).fromSliderValue` then `Options.logMouse`:
|
||||
/// floor(map(v, 0, 1, -200, 101)), the last step clamped, as 10^(i/100).
|
||||
pub(crate) fn from_slider(v: f32) -> f32 {
|
||||
let i = (v.clamp(0.0, 1.0) * 301.0).floor() - 200.0;
|
||||
10.0_f32.powf(i.clamp(-200.0, 100.0) / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The slider position `Opt::WheelSensitivity`'s kind() gives a value.
|
||||
fn wheel_pos(value: f32) -> f32 {
|
||||
match kind(value) {
|
||||
Kind::Slider(p) => p,
|
||||
_ => panic!("the wheel sensitivity is a slider"),
|
||||
}
|
||||
}
|
||||
/// `Options.mouseWheelSensitivity` runs over `IntRange(-200, 100)` through
|
||||
/// `logMouse`/`unlogMouse`: 10^(i/100), 0.01 to 10.0, not a straight line.
|
||||
#[test]
|
||||
fn the_mouse_wheel_sensitivity_slider_is_logarithmic() {
|
||||
// 1.0 is step 0: 200 of the 300 slider steps in, not at 0.099.
|
||||
assert!((wheel_pos(1.0) - 2.0 / 3.0).abs() < 1e-6);
|
||||
assert!((wheel_pos(0.01) - 0.0).abs() < 1e-6);
|
||||
assert!((wheel_pos(10.0) - 1.0).abs() < 1e-6);
|
||||
assert!((from_slider(2.0 / 3.0) - 1.0).abs() < 1e-5);
|
||||
assert!((from_slider(0.0) - 0.01).abs() < 1e-6);
|
||||
assert!((from_slider(1.0) - 10.0).abs() < 1e-4);
|
||||
assert!((from_slider(1.0 / 3.0) - 0.1).abs() < 1e-5);
|
||||
// The value setter goes through the same mapping.
|
||||
let mut s = Settings::default();
|
||||
set_slider(Opt::WheelSensitivity, &mut s, 2.0 / 3.0);
|
||||
assert!((s.mouse_wheel_sensitivity - 1.0).abs() < 1e-5);
|
||||
set_slider(Opt::WheelSensitivity, &mut s, 0.0);
|
||||
assert!((s.mouse_wheel_sensitivity - 0.01).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -277,6 +277,14 @@ pub fn take_text_input_area() -> Option<[f32; 4]> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The tests that change [`STATE`] take turns: it is one for the whole process, and
|
||||
/// cargo runs tests in parallel, so one test's `reset` or `should_suppress_key` could
|
||||
/// land in the middle of another (#14577).
|
||||
static STATE_TESTS: Mutex<()> = Mutex::new(());
|
||||
fn one_at_a_time() -> std::sync::MutexGuard<'static, ()> {
|
||||
STATE_TESTS.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_text_is_no_composition() {
|
||||
assert_eq!(PreeditEvent::from_sdl_text_editing(None, 0, 0), None);
|
||||
@@ -352,6 +360,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_and_clear_preedit() {
|
||||
let _turn = one_at_a_time();
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("nihongo", Some((0, 7))));
|
||||
assert!(composing());
|
||||
@@ -362,6 +371,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_preedit_clears_composing_and_suppresses_trailing_enter_once() {
|
||||
let _turn = one_at_a_time();
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("こんにちは", Some((0, 15))));
|
||||
assert!(composing());
|
||||
@@ -385,6 +395,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mark_committed_suppresses_single_trailing_key() {
|
||||
let _turn = one_at_a_time();
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("漢字", Some((0, 6))));
|
||||
mark_committed();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,13 @@
|
||||
//! or an empty answer keeps the cached lists, `Retry-After` sets the poll interval, and
|
||||
//! `getFriendData` waits out a ten-second cooldown between reads. The presence statuses
|
||||
//! are `PresenceHandler.getPresenceStatus`'s, supplied by the client each tick.
|
||||
mod model;
|
||||
|
||||
use model::presence_of;
|
||||
pub use model::{
|
||||
Friend, FriendData, Presence, PresenceStatus, ResultCode, parse_friends, parse_presence,
|
||||
};
|
||||
|
||||
use crate::auth::{self, AuthSession};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
@@ -23,69 +30,6 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// authlib's `FriendsService.ResultCode`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ResultCode {
|
||||
Success,
|
||||
Error,
|
||||
ServiceNotAvailable,
|
||||
TooManyRequests,
|
||||
Forbidden,
|
||||
UpgradeNeeded,
|
||||
ConnectionIssue,
|
||||
TemporaryUnavailable,
|
||||
UnknownProfile,
|
||||
Unauthorized,
|
||||
GenericError,
|
||||
}
|
||||
|
||||
/// `FriendDto`: one friend or one pending request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Friend {
|
||||
pub id: u128,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// authlib's `FriendData`: the three lists the service answers.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FriendData {
|
||||
pub friends: Vec<Friend>,
|
||||
pub incoming: Vec<Friend>,
|
||||
pub outgoing: Vec<Friend>,
|
||||
}
|
||||
|
||||
/// authlib's `PresenceStatus`, as `updatePresence` stores it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PresenceStatus {
|
||||
Online,
|
||||
PlayingOffline,
|
||||
PlayingRealms,
|
||||
PlayingServer,
|
||||
PlayingHostedServer,
|
||||
Offline,
|
||||
}
|
||||
impl PresenceStatus {
|
||||
/// `PresenceStatus.name()`: the string `updatePresence` sends and
|
||||
/// `PresenceStatusDto.status` carries.
|
||||
fn wire(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "ONLINE",
|
||||
Self::PlayingOffline => "PLAYING_OFFLINE",
|
||||
Self::PlayingRealms => "PLAYING_REALMS",
|
||||
Self::PlayingServer => "PLAYING_SERVER",
|
||||
Self::PlayingHostedServer => "PLAYING_HOSTED_SERVER",
|
||||
Self::Offline => "OFFLINE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PresenceStatusDto`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Presence {
|
||||
pub id: u128,
|
||||
pub status: PresenceStatus,
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler.State`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
@@ -726,104 +670,10 @@ fn http_error(error: ureq::Error) -> Failure {
|
||||
Failure { code }
|
||||
}
|
||||
|
||||
/// authlib's `FriendsListResponse` (`getFriends`/`updateFriends` body).
|
||||
pub fn parse_friends(body: &str) -> Option<FriendData> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
Some(FriendData {
|
||||
friends: parse_friend_list(v.get("friends")),
|
||||
incoming: parse_friend_list(v.get("incomingRequests")),
|
||||
outgoing: parse_friend_list(v.get("outgoingRequests")),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_friend_list(value: Option<&Value>) -> Vec<Friend> {
|
||||
value
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|friend| {
|
||||
Some(Friend {
|
||||
id: auth::parse_uuid(friend.get("profileId")?.as_str()?)?,
|
||||
name: friend.get("name")?.as_str()?.to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// authlib's `PresenceResponse`.
|
||||
pub fn parse_presence(body: &str) -> Option<Vec<Presence>> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
Some(presence_of(&v))
|
||||
}
|
||||
|
||||
/// `PresenceResponse.presence`: a missing list reads as empty, as Gson's null does.
|
||||
fn presence_of(v: &Value) -> Vec<Presence> {
|
||||
v.get("presence")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let status = entry.get("status")?.as_str()?;
|
||||
let status = match status {
|
||||
"ONLINE" => PresenceStatus::Online,
|
||||
"PLAYING_OFFLINE" => PresenceStatus::PlayingOffline,
|
||||
"PLAYING_REALMS" => PresenceStatus::PlayingRealms,
|
||||
"PLAYING_SERVER" => PresenceStatus::PlayingServer,
|
||||
"PLAYING_HOSTED_SERVER" => PresenceStatus::PlayingHostedServer,
|
||||
_ => PresenceStatus::Offline,
|
||||
};
|
||||
Some(Presence {
|
||||
id: auth::parse_uuid(entry.get("profileId")?.as_str()?)?,
|
||||
status,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_friends_answer_reads_its_three_lists() {
|
||||
let data = parse_friends(
|
||||
r#"{"friends":[{"profileId":"0123456789abcdef0123456789abcdef","name":"Alex"}],
|
||||
"incomingRequests":[],"outgoingRequests":[{"profileId":"fedcba9876543210fedcba9876543210","name":"Steve"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
data.friends,
|
||||
vec![Friend {
|
||||
id: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
|
||||
name: "Alex".into()
|
||||
}]
|
||||
);
|
||||
assert!(data.incoming.is_empty());
|
||||
assert_eq!(data.outgoing[0].name, "Steve");
|
||||
// The service's answer to an account with no friends, as the live check
|
||||
// captured it: the three lists plus an `empty` flag the parser ignores.
|
||||
let empty = parse_friends(
|
||||
r#"{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(empty, FriendData::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_presence_answer_reads_each_status() {
|
||||
let presence = parse_presence(
|
||||
r#"{"presence":[
|
||||
{"profileId":"0123456789abcdef0123456789abcdef","pmid":"00000000000000000000000000000000","status":"PLAYING_REALMS","lastUpdated":"2026-11-08T12:00:00Z"},
|
||||
{"profileId":"fedcba9876543210fedcba9876543210","pmid":"00000000000000000000000000000000","status":"OFFLINE","lastUpdated":"2026-11-08T12:00:00Z"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(presence[0].status, PresenceStatus::PlayingRealms);
|
||||
assert_eq!(presence[1].status, PresenceStatus::Offline);
|
||||
// `PresenceResponse` without its list is empty, not missing.
|
||||
assert_eq!(parse_presence("{}"), Some(Vec::new()));
|
||||
assert_eq!(parse_presence("not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_codes_map_to_the_handlers_states() {
|
||||
assert_eq!(State::of(ResultCode::Success), State::Success);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
use crate::auth;
|
||||
use serde_json::Value;
|
||||
|
||||
/// authlib's `FriendsService.ResultCode`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ResultCode {
|
||||
Success,
|
||||
Error,
|
||||
ServiceNotAvailable,
|
||||
TooManyRequests,
|
||||
Forbidden,
|
||||
UpgradeNeeded,
|
||||
ConnectionIssue,
|
||||
TemporaryUnavailable,
|
||||
UnknownProfile,
|
||||
Unauthorized,
|
||||
GenericError,
|
||||
}
|
||||
|
||||
/// `FriendDto`: one friend or one pending request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Friend {
|
||||
pub id: u128,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// authlib's `FriendData`: the three lists the service answers.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FriendData {
|
||||
pub friends: Vec<Friend>,
|
||||
pub incoming: Vec<Friend>,
|
||||
pub outgoing: Vec<Friend>,
|
||||
}
|
||||
|
||||
/// authlib's `PresenceStatus`, as `updatePresence` stores it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PresenceStatus {
|
||||
Online,
|
||||
PlayingOffline,
|
||||
PlayingRealms,
|
||||
PlayingServer,
|
||||
PlayingHostedServer,
|
||||
Offline,
|
||||
}
|
||||
impl PresenceStatus {
|
||||
/// `PresenceStatus.name()`: the string `updatePresence` sends and
|
||||
/// `PresenceStatusDto.status` carries.
|
||||
pub(super) fn wire(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "ONLINE",
|
||||
Self::PlayingOffline => "PLAYING_OFFLINE",
|
||||
Self::PlayingRealms => "PLAYING_REALMS",
|
||||
Self::PlayingServer => "PLAYING_SERVER",
|
||||
Self::PlayingHostedServer => "PLAYING_HOSTED_SERVER",
|
||||
Self::Offline => "OFFLINE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PresenceStatusDto`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Presence {
|
||||
pub id: u128,
|
||||
pub status: PresenceStatus,
|
||||
}
|
||||
|
||||
/// authlib's `FriendsListResponse` (`getFriends`/`updateFriends` body).
|
||||
pub fn parse_friends(body: &str) -> Option<FriendData> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
Some(FriendData {
|
||||
friends: parse_friend_list(v.get("friends")),
|
||||
incoming: parse_friend_list(v.get("incomingRequests")),
|
||||
outgoing: parse_friend_list(v.get("outgoingRequests")),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_friend_list(value: Option<&Value>) -> Vec<Friend> {
|
||||
value
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|friend| {
|
||||
Some(Friend {
|
||||
id: auth::parse_uuid(friend.get("profileId")?.as_str()?)?,
|
||||
name: friend.get("name")?.as_str()?.to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// authlib's `PresenceResponse`.
|
||||
pub fn parse_presence(body: &str) -> Option<Vec<Presence>> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
Some(presence_of(&v))
|
||||
}
|
||||
|
||||
/// `PresenceResponse.presence`: a missing list reads as empty, as Gson's null does.
|
||||
pub(super) fn presence_of(v: &Value) -> Vec<Presence> {
|
||||
v.get("presence")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let status = entry.get("status")?.as_str()?;
|
||||
let status = match status {
|
||||
"ONLINE" => PresenceStatus::Online,
|
||||
"PLAYING_OFFLINE" => PresenceStatus::PlayingOffline,
|
||||
"PLAYING_REALMS" => PresenceStatus::PlayingRealms,
|
||||
"PLAYING_SERVER" => PresenceStatus::PlayingServer,
|
||||
"PLAYING_HOSTED_SERVER" => PresenceStatus::PlayingHostedServer,
|
||||
_ => PresenceStatus::Offline,
|
||||
};
|
||||
Some(Presence {
|
||||
id: auth::parse_uuid(entry.get("profileId")?.as_str()?)?,
|
||||
status,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_friends_answer_reads_its_three_lists() {
|
||||
let data = parse_friends(
|
||||
r#"{"friends":[{"profileId":"0123456789abcdef0123456789abcdef","name":"Alex"}],
|
||||
"incomingRequests":[],"outgoingRequests":[{"profileId":"fedcba9876543210fedcba9876543210","name":"Steve"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
data.friends,
|
||||
vec![Friend {
|
||||
id: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
|
||||
name: "Alex".into()
|
||||
}]
|
||||
);
|
||||
assert!(data.incoming.is_empty());
|
||||
assert_eq!(data.outgoing[0].name, "Steve");
|
||||
// The service's answer to an account with no friends, as the live check
|
||||
// captured it: the three lists plus an `empty` flag the parser ignores.
|
||||
let empty = parse_friends(
|
||||
r#"{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(empty, FriendData::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_presence_answer_reads_each_status() {
|
||||
let presence = parse_presence(
|
||||
r#"{"presence":[
|
||||
{"profileId":"0123456789abcdef0123456789abcdef","pmid":"00000000000000000000000000000000","status":"PLAYING_REALMS","lastUpdated":"2026-11-08T12:00:00Z"},
|
||||
{"profileId":"fedcba9876543210fedcba9876543210","pmid":"00000000000000000000000000000000","status":"OFFLINE","lastUpdated":"2026-11-08T12:00:00Z"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(presence[0].status, PresenceStatus::PlayingRealms);
|
||||
assert_eq!(presence[1].status, PresenceStatus::Offline);
|
||||
// `PresenceResponse` without its list is empty, not missing.
|
||||
assert_eq!(parse_presence("{}"), Some(Vec::new()));
|
||||
assert_eq!(parse_presence("not json"), None);
|
||||
}
|
||||
}
|
||||
@@ -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! {
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"horizon.no_resources.title": "No prepared game files",
|
||||
"horizon.no_resources.prepare": "Prepare them with horizon-prepare --prepare, or pass --resources <folder>.",
|
||||
"horizon.no_resources.looked_in": "No prepared game files in %s",
|
||||
"horizon.no_resources.quit": "Quit",
|
||||
"horizon.get_the_game.title": "Start Horizon from the launcher",
|
||||
"horizon.get_the_game.about": "This is the game the Horizon launcher installs and starts. On its own it has none of Minecraft's files, so it can't start.",
|
||||
"horizon.get_the_game.minecraft": "Horizon needs your own copy of Minecraft: Java Edition. The launcher signs you in and prepares the game files from it.",
|
||||
"horizon.get_the_game.download": "Download the launcher at %s, then press Play there.",
|
||||
"horizon.get_the_game.button": "Download the launcher",
|
||||
"horizon.extras.header.world": "World",
|
||||
"horizon.extras.pregen": "Chunk pregeneration",
|
||||
"horizon.extras.pregen.tooltip": "Enables /pregen in your single-player and LAN worlds. With cheats enabled, use /pregen start to generate an area before exploring it. Does not enable the command on servers you join.",
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -85,6 +85,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());
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ pub fn conversion_tags(raw: Option<&Tag>) -> Option<Tag> {
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
mod refs;
|
||||
pub use refs::who_by_uuid;
|
||||
/// Entity type ID (`id`).
|
||||
pub fn kind(tag: &Tag) -> Option<String> {
|
||||
horizon_client_core::identifier::normalize_id(&text(tag.get("id"))?)
|
||||
@@ -284,28 +286,6 @@ pub fn migrate_player_uuid(tag: &mut Tag, old: [i32; 4], new: [i32; 4]) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// The entity a stored UUID names, while it is in the level: `player` (the
|
||||
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
|
||||
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
|
||||
/// of the pair [`who_uuid`] and `mob_to_nbt`'s `uuid_of` form. A UUID nothing
|
||||
/// loaded has, or one naming a kind `Who` cannot carry (an item, a projectile, a
|
||||
/// guest), is `None` - never some other entity as a stand-in.
|
||||
pub fn who_by_uuid(
|
||||
uuids: &HashMap<u32, [i32; 4]>,
|
||||
mobs: &[Mob],
|
||||
player: Option<[i32; 4]>,
|
||||
uuid: [i32; 4],
|
||||
) -> Option<Who> {
|
||||
if player == Some(uuid) {
|
||||
return Some(Who::Player);
|
||||
}
|
||||
// Only a loaded mob: the map also holds projectiles, vehicles and items,
|
||||
// whose ids mean nothing to `Who`.
|
||||
uuids
|
||||
.iter()
|
||||
.find(|(id, u)| **u == uuid && mobs.iter().any(|m| m.id == **id))
|
||||
.map(|(id, _)| Who::Mob(*id))
|
||||
}
|
||||
/// `AngerManagement.SUSPECT_ANGER_PAIR`: one suspect's UUID and how angry the
|
||||
/// warden is at it.
|
||||
fn suspect_anger(uuid: [i32; 4], anger: i32) -> Tag {
|
||||
@@ -1257,11 +1237,11 @@ pub fn mob_to_nbt(
|
||||
));
|
||||
}
|
||||
// ITEM_PICKUP_COOLDOWN_TICKS (`Codec.INT`), which a throw sets to 60,
|
||||
// while it runs.
|
||||
if m.extra.ints[3] > 0 {
|
||||
// while the memory is there (its value 0 included).
|
||||
if let Some(ticks) = crate::mobs::allay::pickup_cooldown(m) {
|
||||
memories.push((
|
||||
"minecraft:item_pickup_cooldown_ticks",
|
||||
Tag::compound([("value", Tag::Int(m.extra.ints[3]))]),
|
||||
Tag::compound([("value", Tag::Int(ticks))]),
|
||||
));
|
||||
}
|
||||
// Always written, so a forgotten note block does not come back
|
||||
@@ -2063,6 +2043,7 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.death_loot_table = text(tag.get("DeathLootTable"))
|
||||
.and_then(|t| horizon_client_core::identifier::normalize_id(&t));
|
||||
m.extra.death_loot_seed = number(tag.get("DeathLootTableSeed")).unwrap_or(0);
|
||||
refs::read_last_hurt_by(&mut m, tag);
|
||||
// `NeutralMob.readPersistentAngerSaveData`: the anger end time and the
|
||||
// target come back with the mob.
|
||||
if persistent_anger(m.species()) {
|
||||
@@ -2488,8 +2469,8 @@ pub fn mob_from_nbt_with_random(
|
||||
};
|
||||
m.extra.ints[2] =
|
||||
number(memory("minecraft:liked_noteblock_cooldown_ticks")).unwrap_or(0) as i32;
|
||||
m.extra.ints[3] =
|
||||
number(memory("minecraft:item_pickup_cooldown_ticks")).unwrap_or(0) as i32;
|
||||
let pickup = number(memory("minecraft:item_pickup_cooldown_ticks")).map(|t| t as i32);
|
||||
crate::mobs::allay::set_pickup_cooldown(&mut m, pickup);
|
||||
if let Some(st) = m.extra.allay.as_mut()
|
||||
&& let Some(noteblock) = memory("minecraft:liked_noteblock")
|
||||
&& let Some(Tag::IntArray(p)) = noteblock.get("pos")
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! `EntityReference`s a mob's compound holds: a UUID, resolved against what is in
|
||||
//! the level.
|
||||
use super::{Who, number, uuid_from_tag};
|
||||
use crate::mobs::Mob;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `LivingEntity.readAdditionalSaveData`: `last_hurt_by_mob` is a lazy
|
||||
/// `EntityReference` (the UUID, which the game resolves once the entity is in
|
||||
/// the level) and its timestamp is `tickCount - ticks_since_last_hurt_by_mob`.
|
||||
pub(super) fn read_last_hurt_by(m: &mut Mob, tag: &Tag) {
|
||||
m.ai.last_hurt_by_ref = uuid_from_tag(tag.get("last_hurt_by_mob"));
|
||||
m.ai.last_hurt_by_time = (m.age as i32)
|
||||
.wrapping_sub(number(tag.get("ticks_since_last_hurt_by_mob")).unwrap_or(0) as i32);
|
||||
}
|
||||
|
||||
/// The entity a stored UUID names, while it is in the level: `player` (the
|
||||
/// player's own UUID, when one is in the level), or the mob `uuids` gives that
|
||||
/// UUID to. `uuids` is the game layer's id -> UUID map, so this is the read half
|
||||
/// of the pair [`who_uuid`] and `mob_to_nbt`'s `uuid_of` form. A UUID nothing
|
||||
/// loaded has, or one naming a kind `Who` cannot carry (an item, a projectile, a
|
||||
/// guest), is `None` - never some other entity as a stand-in.
|
||||
pub fn who_by_uuid(
|
||||
uuids: &HashMap<u32, [i32; 4]>,
|
||||
mobs: &[Mob],
|
||||
player: Option<[i32; 4]>,
|
||||
uuid: [i32; 4],
|
||||
) -> Option<Who> {
|
||||
if player == Some(uuid) {
|
||||
return Some(Who::Player);
|
||||
}
|
||||
// Only a loaded mob: the map also holds projectiles, vehicles and items,
|
||||
// whose ids mean nothing to `Who`.
|
||||
uuids
|
||||
.iter()
|
||||
.find(|(id, u)| **u == uuid && mobs.iter().any(|m| m.id == **id))
|
||||
.map(|(id, _)| Who::Mob(*id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
use crate::mobs::{Species, sensor_scan_tests::kind};
|
||||
|
||||
fn types() -> HashMap<String, std::sync::Arc<crate::mobs::MobType>> {
|
||||
let mut pig = (*kind()).clone();
|
||||
pig.id = "minecraft:pig".into();
|
||||
pig.species = Species::Pig;
|
||||
HashMap::from([("minecraft:pig".to_owned(), std::sync::Arc::new(pig))])
|
||||
}
|
||||
|
||||
fn load(snbt: &str) -> Mob {
|
||||
let tag = horizon_world::nbt::parse_snbt(snbt).unwrap().0;
|
||||
mob_from_nbt(&tag, &types(), 7, None).unwrap()
|
||||
}
|
||||
|
||||
/// `LivingEntity.readAdditionalSaveData`: `lastHurtByMob` is the UUID,
|
||||
/// `lastHurtByMobTimestamp = tickCount - ticks_since_last_hurt_by_mob`, and a
|
||||
/// freshly loaded entity has `tickCount` 0, so 30 saved ticks make -30.
|
||||
#[test]
|
||||
fn a_saved_hurt_by_mob_is_read_back_as_a_reference_and_a_timestamp() {
|
||||
let m = load(
|
||||
r#"{id:"minecraft:pig",UUID:[I;5,0,0,1],Pos:[2.5d,200d,4.5d],Health:10f,last_hurt_by_mob:[I;5,0,0,2],ticks_since_last_hurt_by_mob:30}"#,
|
||||
);
|
||||
assert_eq!(m.age, 0);
|
||||
assert_eq!(m.ai.last_hurt_by_ref, Some([5, 0, 0, 2]));
|
||||
assert_eq!(m.ai.last_hurt_by_time, -30);
|
||||
// Not resolved yet: `getLastHurtByMob` answers nothing.
|
||||
assert_eq!(m.ai.last_hurt_by, None);
|
||||
}
|
||||
|
||||
/// Missing keys: no reference, and `getIntOr("ticks_since...", 0)` stamps the
|
||||
/// timestamp at `tickCount`.
|
||||
#[test]
|
||||
fn a_save_without_a_hurt_by_mob_has_none() {
|
||||
let m = load(r#"{id:"minecraft:pig",UUID:[I;5,0,0,1],Pos:[2.5d,200d,4.5d],Health:10f}"#);
|
||||
assert_eq!(m.ai.last_hurt_by_ref, None);
|
||||
assert_eq!(m.ai.last_hurt_by_time, 0);
|
||||
// A key that is not a UUID int array is no reference (the codec fails).
|
||||
let m = load(
|
||||
r#"{id:"minecraft:pig",UUID:[I;5,0,0,1],Pos:[2.5d,200d,4.5d],Health:10f,last_hurt_by_mob:"x"}"#,
|
||||
);
|
||||
assert_eq!(m.ai.last_hurt_by_ref, None);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -758,7 +758,6 @@ enum GuestUse {
|
||||
/// an entity again from the view.
|
||||
Entity(Hand, InteractTarget),
|
||||
}
|
||||
|
||||
/// The entity an interact packet names and where its client clicked it (the
|
||||
/// packet's location, measured from the entity's position): what
|
||||
/// `ServerGamePacketListenerImpl.handleInteract` takes from
|
||||
@@ -1505,7 +1504,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 +2327,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 +5034,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 +7159,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 +8205,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",
|
||||
@@ -8490,6 +8481,7 @@ impl Game<'_> {
|
||||
None,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.vsync,
|
||||
&self.settings.horizon,
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
display,
|
||||
@@ -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(),
|
||||
@@ -12480,7 +12462,7 @@ pub fn run_with() -> Result<()> {
|
||||
horizon_world::datafix::set_resources(
|
||||
&std::path::absolute(&vanilla_root).unwrap_or_else(|_| vanilla_root.clone()),
|
||||
);
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
if let Some(error) = missing_resources(&vanilla_root, horizon_hooks::engine::get_the_game()) {
|
||||
// The game build started by hand (the release's zip), not by the
|
||||
// launcher: a window says where to get it. Scripts and servers get
|
||||
// the error.
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! same on vanilla 26.3).
|
||||
use super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::mobs::allay;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
@@ -84,7 +85,7 @@ fn an_allay_keeps_its_item_pickup_cooldown_memory() {
|
||||
let mut game = game();
|
||||
let i = index(&mut game);
|
||||
// The supplied memory is loaded into the pickup cooldown and saved back.
|
||||
assert_eq!(game.mobs[i].extra.ints[3], 30, "loaded");
|
||||
assert_eq!(allay::pickup_cooldown(&game.mobs[i]), Some(30), "loaded");
|
||||
assert_eq!(
|
||||
saved(&mut game, i),
|
||||
Some(Tag::compound([("value", Tag::Int(30))])),
|
||||
@@ -100,6 +101,53 @@ fn an_allay_keeps_its_item_pickup_cooldown_memory() {
|
||||
Some(Tag::compound([("value", Tag::Int(20))])),
|
||||
"after 10 ticks"
|
||||
);
|
||||
// CountDownCooldownTicks: the memory reaches 0 and is erased a tick later
|
||||
// (`stop`).
|
||||
step(&mut game, 20);
|
||||
assert_eq!(
|
||||
saved(&mut game, i),
|
||||
Some(Tag::compound([("value", Tag::Int(0))])),
|
||||
"at 0"
|
||||
);
|
||||
step(&mut game, 1);
|
||||
assert_eq!(saved(&mut game, i), None, "erased the tick after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn pickup_cooldown_saves_the_full_integer_value() {
|
||||
let mut game = game();
|
||||
let i = index(&mut game);
|
||||
assert!(game.run_command(&format!(
|
||||
"item replace entity {SUPPLIED} weapon.mainhand with minecraft:diamond 1"
|
||||
)));
|
||||
for ticks in [i32::MIN, -1, 0, 1, i32::MAX] {
|
||||
allay::set_pickup_cooldown(&mut game.mobs[i], Some(ticks));
|
||||
assert!(
|
||||
!game.mobs[i].can_pick_up_loot(),
|
||||
"memory present at {ticks}"
|
||||
);
|
||||
assert_eq!(
|
||||
saved(&mut game, i),
|
||||
Some(Tag::compound([("value", Tag::Int(ticks))])),
|
||||
"saved {ticks}"
|
||||
);
|
||||
let tag = game.mob_tree_nbt(i);
|
||||
let loaded = horizon_client::entity_nbt::mob_from_nbt(
|
||||
&tag,
|
||||
game.res.mob_types(),
|
||||
game.mobs[i].id,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
allay::pickup_cooldown(&loaded),
|
||||
Some(ticks),
|
||||
"loaded {ticks}"
|
||||
);
|
||||
}
|
||||
allay::set_pickup_cooldown(&mut game.mobs[i], None);
|
||||
assert!(game.mobs[i].can_pick_up_loot(), "memory erased");
|
||||
}
|
||||
|
||||
// Added controls (after the frozen baseline above).
|
||||
@@ -135,10 +183,10 @@ fn the_saved_pickup_cooldown_follows_the_model() {
|
||||
assert_eq!(saved(&mut game, plain), None, "no raw memory");
|
||||
// A loaded memory the model has ended: no key (the raw copy is not echoed).
|
||||
let supplied = index(&mut game);
|
||||
game.mobs[supplied].extra.ints[3] = 0;
|
||||
allay::set_pickup_cooldown(&mut game.mobs[supplied], None);
|
||||
assert_eq!(saved(&mut game, supplied), None, "stale raw memory");
|
||||
// A model mutation, as a throw does (`allays.rs`): saved with its value.
|
||||
game.mobs[plain].extra.ints[3] = 60;
|
||||
allay::set_pickup_cooldown(&mut game.mobs[plain], Some(60));
|
||||
let tag = saved(&mut game, plain);
|
||||
assert_eq!(
|
||||
tag,
|
||||
@@ -149,7 +197,11 @@ fn the_saved_pickup_cooldown_follows_the_model() {
|
||||
let whole = game.mob_tree_nbt(plain);
|
||||
let reloaded =
|
||||
horizon_client::entity_nbt::mob_from_nbt(&whole, game.res.mob_types(), 99, None).unwrap();
|
||||
assert_eq!(reloaded.extra.ints[3], 60, "load after save");
|
||||
assert_eq!(
|
||||
allay::pickup_cooldown(&reloaded),
|
||||
Some(60),
|
||||
"load after save"
|
||||
);
|
||||
// NoAI: no Brain tick, the value stays.
|
||||
let frozen = at(&mut game, 2);
|
||||
step(&mut game, 5);
|
||||
|
||||
@@ -288,7 +288,7 @@ impl Game<'_> {
|
||||
});
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.allay.as_mut().unwrap().take(1);
|
||||
m.extra.ints[3] = 60;
|
||||
allay::set_pickup_cooldown(m, Some(60));
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(stack, from, v, 10);
|
||||
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
|
||||
|
||||
@@ -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"),
|
||||
] {
|
||||
|
||||
@@ -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 {
|
||||
@@ -731,6 +737,54 @@ fn damage_taken_award(amount: f32) -> Option<i32> {
|
||||
(amount < 3.402_823_5e37).then(|| horizon_core::jmath::round_f32(amount * 10.0))
|
||||
}
|
||||
|
||||
/// Hits one mob's tick deals another, handled after the mob loop.
|
||||
impl Game<'_> {
|
||||
/// A melee hit on another mob, then `postAttack`; whether it landed.
|
||||
pub(super) fn mob_attacks_mob(
|
||||
&mut self,
|
||||
target: u32,
|
||||
amount: f32,
|
||||
source: horizon_client::mobs::Damage,
|
||||
) -> bool {
|
||||
let hurt = self.mob_hits_mob(target, amount, source);
|
||||
if hurt {
|
||||
self.mob_post_attack(
|
||||
target,
|
||||
source.attacker,
|
||||
None,
|
||||
Some(super::creatures::damage_type(source.kind, false)),
|
||||
);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// Whether a hit from a mob's tick on `target`, handled after the mob
|
||||
/// loop, comes after a tick of the target that vanilla runs after the hit:
|
||||
/// `ServerLevel.tick` ticks entities in list order and the attacker deals
|
||||
/// the hit in its own tick, so a target later in the list counts its
|
||||
/// `HurtTime` and invulnerability down once more that tick. PARTIAL: the
|
||||
/// rest of that tick (its movement, its own goals) still ran before the hit.
|
||||
/// The caller compensates only newly reset timers: a stronger hit during
|
||||
/// cooldown leaves timers that already counted down this tick unchanged.
|
||||
pub(super) fn catch_up_countdowns(
|
||||
&self,
|
||||
attacker: Option<horizon_client::mobs::Who>,
|
||||
target: u32,
|
||||
) -> bool {
|
||||
let Some(horizon_client::mobs::Who::Mob(attacker)) = attacker else {
|
||||
return false;
|
||||
};
|
||||
let ticked = &self.creatures.ticked;
|
||||
if !self.creatures.deferred_hits || !ticked.contains(&attacker) || !ticked.contains(&target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// The mobs added during the tick come first in `self.mobs`; the
|
||||
// ticked ones keep their tick order after them.
|
||||
let at = |id: u32| self.mobs.iter().position(|m| m.id == id);
|
||||
matches!((at(attacker), at(target)), (Some(a), Some(t)) if a < t)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod damage_taken_tests {
|
||||
use super::damage_taken_award;
|
||||
|
||||
@@ -274,6 +274,8 @@ impl Game<'_> {
|
||||
let command = op["cmd"].as_str().unwrap_or("");
|
||||
self.chat_capture = Some(Vec::new());
|
||||
self.cmds.results = Some(Vec::new());
|
||||
let earlier = self.adv.announced.len();
|
||||
self.cmds.broadcast_announced = earlier;
|
||||
self.harness_owner(|g| {
|
||||
let previous = g.command_source;
|
||||
if op["source"].as_str() == Some("console") {
|
||||
@@ -290,7 +292,24 @@ impl Game<'_> {
|
||||
g.command_source = previous;
|
||||
Ok(())
|
||||
})?;
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
// What the command's own `broadcastChanges` earned is announced
|
||||
// before its feedback, as a guest's command sends it
|
||||
// (`announce_guest_advancements`); the rest is left for the tick.
|
||||
let earned: Vec<_> = {
|
||||
let len = self.adv.announced.len();
|
||||
let end = self.cmds.broadcast_announced.clamp(earlier.min(len), len);
|
||||
self.adv.announced.drain(earlier.min(len)..end).collect()
|
||||
};
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
for text in &earned {
|
||||
host.hub
|
||||
.system_message(horizon_client::lan::data::component_nbt(
|
||||
&text.component(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut messages = earned;
|
||||
messages.extend(self.chat_capture.take().unwrap_or_default());
|
||||
let results = self.cmds.results.take().unwrap_or_default();
|
||||
if op["record"].as_bool() != Some(false) || op["trace"].as_bool() == Some(true)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -147,6 +147,11 @@ pub(in crate::game) struct CommandExtras {
|
||||
/// The `CommandResultCallback` results of the outermost source, when a
|
||||
/// harness records them.
|
||||
pub results: Option<Vec<(bool, i32)>>,
|
||||
/// How many of `adv.announced` a command's own `broadcastChanges` had
|
||||
/// made: the harness records those with the command, and leaves what
|
||||
/// `run_command`'s check after it earns for the next tick, as vanilla
|
||||
/// does for a command that calls no `broadcastChanges` (`/loot give`).
|
||||
pub broadcast_announced: usize,
|
||||
/// The running `/pregen` task.
|
||||
pub pregen: Option<super::pregen::PregenTask>,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
@@ -1415,6 +1424,13 @@ impl Game<'_> {
|
||||
}
|
||||
let h = self.owner_holder(o);
|
||||
tracker.track(&h, changed);
|
||||
// EntityItemAccessor: a player whose slots changed gets
|
||||
// `containerMenu.broadcastChanges` at once, so InventoryChangeTrigger
|
||||
// (and its announcement) comes before the command's message.
|
||||
if changed > 0 && matches!(o, Owner::Entity(Target::Player)) && self.player.is_some() {
|
||||
self.check_inventory_trigger();
|
||||
self.cmds.broadcast_announced = self.adv.announced.len();
|
||||
}
|
||||
}
|
||||
if selected == 0 {
|
||||
return Err(
|
||||
@@ -1979,6 +1995,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,6 +2,7 @@
|
||||
//! 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 projectile_world;
|
||||
mod pufferfish_player;
|
||||
@@ -122,6 +123,8 @@ pub(super) struct Creatures {
|
||||
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
|
||||
/// (pickups, item searches) is for these alone.
|
||||
pub ticked: horizon_client::fast_hash::FxHashSet<u32>,
|
||||
/// While the mob loop's own events are handled (`catch_up_countdowns`).
|
||||
pub deferred_hits: bool,
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
|
||||
@@ -1145,8 +1148,8 @@ impl Game<'_> {
|
||||
/// `DistanceManager.inEntityTickingRange` in an entity-ticking chunk
|
||||
/// (block coordinates), from every player's tickets (`update_chunk_tickets`).
|
||||
pub(super) fn entity_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
self.tickets
|
||||
.entity_ticking_area(&self.world.chunks, self.remote.is_none())
|
||||
let levels = self.tickets.levels();
|
||||
move |x, z| levels.entity_ticking_at(x, z)
|
||||
}
|
||||
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
|
||||
/// projectiles and effects that follow from them.
|
||||
@@ -1165,6 +1168,9 @@ impl Game<'_> {
|
||||
self.died.insert(m.id);
|
||||
self.mob_died(&m);
|
||||
}
|
||||
// A `last_hurt_by_mob` read from a save names its attacker once that
|
||||
// entity is here, before the mobs' `baseTick`s.
|
||||
self.resolve_last_hurt_by_refs();
|
||||
// Their victims have been looted and mourned: no charged creeper is
|
||||
// waiting any more.
|
||||
self.combat.charged_exploders.clear();
|
||||
@@ -1609,7 +1615,9 @@ impl Game<'_> {
|
||||
let bounds = m.bounds();
|
||||
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
|
||||
}
|
||||
self.creatures.deferred_hits = true;
|
||||
let event_result = self.handle_mob_events_reusable(&mut events);
|
||||
self.creatures.deferred_hits = false;
|
||||
self.creatures.event_buf = events;
|
||||
event_result?;
|
||||
self.lan_guest_mob_events(guest_events);
|
||||
@@ -2123,18 +2131,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `Mob.doHurtTarget` and the other melee callers of post-attack effects.
|
||||
fn mob_attacks_mob(&mut self, target: u32, amount: f32, source: Damage) -> bool {
|
||||
let hurt = self.mob_hits_mob(target, amount, source);
|
||||
if hurt {
|
||||
self.mob_post_attack(
|
||||
target,
|
||||
source.attacker,
|
||||
None,
|
||||
Some(damage_type(source.kind, false)),
|
||||
);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// Melee or ranged damage to another mob; whether the hit landed.
|
||||
pub(super) fn mob_hits_mob(&mut self, target: u32, amount: f32, mut source: Damage) -> bool {
|
||||
if source.attacker_species.is_none()
|
||||
@@ -2145,12 +2141,19 @@ impl Game<'_> {
|
||||
let mut killed = false;
|
||||
let mut hurt = false;
|
||||
let mut infested = Vec::new();
|
||||
let catch_up = self.catch_up_countdowns(source.attacker, target);
|
||||
let aim = self.attacker_aim(source.attacker);
|
||||
// So `living_knockback_v2` sees who hit and what they hold.
|
||||
let _attacker = self.knockback_scope_for(source.attacker);
|
||||
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
let previous_invulnerable = t.invulnerable;
|
||||
let hit = t.damage_aimed(amount, source, aim);
|
||||
hurt = hit;
|
||||
if hit && catch_up && t.invulnerable > previous_invulnerable {
|
||||
// `LivingEntity.baseTick`'s countdowns, run after the hit in vanilla.
|
||||
t.hurt_time = (t.hurt_time - 1).max(0);
|
||||
t.invulnerable = (t.invulnerable - 1).max(0);
|
||||
}
|
||||
if hit {
|
||||
killed = !t.alive();
|
||||
// The hurt or death sound the mob raised.
|
||||
@@ -2222,22 +2225,6 @@ impl Game<'_> {
|
||||
None => self.last_hurt_by_player.remove(&victim),
|
||||
};
|
||||
}
|
||||
/// The player in the world with `uuid` (`EntityReference.getPlayer`), as
|
||||
/// `last_hurt_by_player` keeps them: None for the player in place, else the
|
||||
/// guest's connection.
|
||||
fn player_with_uuid(&self, uuid: [i32; 4]) -> Option<Option<u32>> {
|
||||
if self.player.is_some()
|
||||
&& horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid())
|
||||
{
|
||||
return Some(self.lan_acting);
|
||||
}
|
||||
let uuid = horizon_client::lan::playerdata::ints_uuid(uuid);
|
||||
self.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter())
|
||||
.find(|g| g.placed && g.uuid == uuid)
|
||||
.map(|g| Some(g.conn))
|
||||
}
|
||||
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
|
||||
/// sequence with the mob as `this` (a chicken's egg depends on its
|
||||
/// variant); the loot data of the game's resources decides, and without it
|
||||
@@ -3869,18 +3856,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 +3876,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 +3928,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 +4043,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 +4532,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 +4640,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 +4775,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 +4848,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 +4965,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 +5149,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 +5176,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 + '_ {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ mod transfer;
|
||||
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
|
||||
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
|
||||
struct Watchdog {
|
||||
/// The thread running the ticks, whose stack the report carries.
|
||||
server: horizon_threads::stack_dump::Watched,
|
||||
base: Instant,
|
||||
next_nanos: AtomicU64,
|
||||
tick_nanos: AtomicU64,
|
||||
@@ -53,9 +55,11 @@ struct Watchdog {
|
||||
impl Watchdog {
|
||||
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
|
||||
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
|
||||
/// Started from the thread that runs the ticks.
|
||||
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
|
||||
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
|
||||
let watchdog = Arc::new(Self {
|
||||
server: horizon_threads::stack_dump::Watched::current(),
|
||||
base: Instant::now(),
|
||||
next_nanos: AtomicU64::new(0),
|
||||
tick_nanos: AtomicU64::new(50_000_000),
|
||||
@@ -84,6 +88,8 @@ impl Watchdog {
|
||||
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
|
||||
let behind = now.saturating_sub(next);
|
||||
if behind > max {
|
||||
// ServerWatchdog: the server thread's stack first, while it is still stuck.
|
||||
let stack = self.server.stack(Duration::from_secs(5));
|
||||
let took = format!("{:.2}", behind as f32 / 1e9);
|
||||
let should = format!(
|
||||
"{:.2}",
|
||||
@@ -106,6 +112,7 @@ impl Watchdog {
|
||||
std::path::Path::new("."),
|
||||
"server",
|
||||
message,
|
||||
stack,
|
||||
) {
|
||||
Some(path) => log_from(
|
||||
"Server Watchdog",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -3672,9 +3672,9 @@ impl Dragon {
|
||||
let mut d = Self::new(position, yaw, seed);
|
||||
d.velocity = velocity;
|
||||
// Vanilla getFloatOr/getIntOr accept any numeric tag type.
|
||||
if let Some(h) = nbt_value::float(tag.get("Health")) {
|
||||
d.health = h as f32;
|
||||
}
|
||||
d.health = nbt_value::float(tag.get("Health")).map_or(d.health, |h| h as f32);
|
||||
d.sitting_damage =
|
||||
nbt_value::float(tag.get("sitting_damage_received")).unwrap_or(0.0) as f32;
|
||||
if let Some(p) = nbt_value::number(tag.get("DragonPhase")) {
|
||||
d.phase = Phase::from_id(p as i32);
|
||||
}
|
||||
@@ -5082,6 +5082,25 @@ mod tests {
|
||||
d.phase = phase;
|
||||
d
|
||||
}
|
||||
|
||||
/// `EnderDragon.readAdditionalSaveData` reads `sitting_damage_received`
|
||||
/// (`getFloatOr`, 0 when absent); the writer already saved it. Horizon
|
||||
/// only wrote it, so a perched dragon that took a quarter of its health
|
||||
/// but was saved before taking off came back with the damage forgotten.
|
||||
#[test]
|
||||
fn a_perched_dragons_damage_survives_the_chunk_save() {
|
||||
let mut d = dragon(Phase::Flaming { ticks: 0 });
|
||||
d.sitting_damage = 12.5;
|
||||
let tag = d.to_nbt([1, 2, 3, 4], None);
|
||||
let back = Dragon::from_nbt(&tag, 0).unwrap();
|
||||
assert_eq!(back.sitting_damage, 12.5);
|
||||
// No key at all reads as no damage accumulated.
|
||||
let mut bare = tag.clone();
|
||||
if let horizon_world::nbt::Tag::Compound(fields) = &mut bare {
|
||||
fields.remove(&"sitting_damage_received".into());
|
||||
}
|
||||
assert_eq!(Dragon::from_nbt(&bare, 0).unwrap().sitting_damage, 0.0);
|
||||
}
|
||||
fn flying() -> Phase {
|
||||
Phase::Holding {
|
||||
path: None,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1322,6 +1337,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 +1415,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 +1744,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 +1874,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 +1944,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);
|
||||
});
|
||||
}
|
||||
@@ -1448,7 +1448,8 @@ impl Game<'_> {
|
||||
/// running to that player's hand.
|
||||
pub(super) struct RemoteHook {
|
||||
id: u32,
|
||||
owner: u32,
|
||||
pub(super) owner: u32,
|
||||
pub(super) owner_fishing: bool,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
velocity: Vec3,
|
||||
@@ -1458,6 +1459,34 @@ pub(super) struct RemoteHook {
|
||||
pub(super) random: LegacyRandom,
|
||||
}
|
||||
|
||||
fn push_remote_hook(hooks: &mut Vec<RemoteHook>, id: u32, owner: u32, at: Vec3, velocity: Vec3) {
|
||||
for h in hooks.iter_mut().filter(|h| h.owner == owner) {
|
||||
h.owner_fishing = false;
|
||||
}
|
||||
hooks.push(RemoteHook {
|
||||
id,
|
||||
owner,
|
||||
owner_fishing: true,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
state: State::Flying,
|
||||
on_ground: false,
|
||||
lerp: HookLerp::default(),
|
||||
random: random_source::entity(),
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_remote_hook_from(hooks: &mut Vec<RemoteHook>, id: u32) {
|
||||
let owner = hooks.iter().find(|h| h.id == id).map(|h| h.owner);
|
||||
hooks.retain(|h| h.id != id);
|
||||
if let Some(owner) = owner {
|
||||
for h in hooks.iter_mut().filter(|h| h.owner == owner) {
|
||||
h.owner_fishing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `onGround` of a server's update (`handleMoveEntity`), except that a
|
||||
/// hook resting on the ground stays so while the update does not lift it: the
|
||||
/// server's tracker can send the flag a tick behind the landing.
|
||||
@@ -1604,17 +1633,7 @@ impl Game<'_> {
|
||||
hook.velocity = velocity;
|
||||
self.hook = Some(hook);
|
||||
} else {
|
||||
self.remote_hooks.push(RemoteHook {
|
||||
id,
|
||||
owner: owner as u32,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
state: State::Flying,
|
||||
on_ground: false,
|
||||
lerp: HookLerp::default(),
|
||||
random: random_source::entity(),
|
||||
});
|
||||
push_remote_hook(&mut self.remote_hooks, id, owner as u32, at, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1920,7 +1939,7 @@ impl Game<'_> {
|
||||
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
}
|
||||
self.remote_hooks.retain(|h| h.id != id);
|
||||
remove_remote_hook_from(&mut self.remote_hooks, id);
|
||||
}
|
||||
|
||||
pub(super) fn clear_remote_hooks(&mut self) {
|
||||
@@ -2310,4 +2329,19 @@ mod tests {
|
||||
);
|
||||
assert_eq!(pulled, Vec3::new(1.5, 0.2, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_one_of_two_remote_hooks_clears_owner_fishing() {
|
||||
let mut hooks = Vec::new();
|
||||
super::push_remote_hook(&mut hooks, 10, 7, Vec3::ZERO, Vec3::ZERO);
|
||||
super::push_remote_hook(&mut hooks, 11, 7, Vec3::ZERO, Vec3::ZERO);
|
||||
super::push_remote_hook(&mut hooks, 20, 12, Vec3::ZERO, Vec3::ZERO);
|
||||
assert!(!hooks[0].owner_fishing);
|
||||
assert!(hooks[1].owner_fishing);
|
||||
assert!(hooks[2].owner_fishing);
|
||||
super::remove_remote_hook_from(&mut hooks, 10);
|
||||
assert_eq!(hooks.len(), 2);
|
||||
assert!(!hooks.iter().any(|h| h.owner == 7 && h.owner_fishing));
|
||||
assert!(hooks.iter().any(|h| h.owner == 12 && h.owner_fishing));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -133,6 +133,58 @@ fn a_hoglin_keeps_attacking_its_attacker_beside_another_hit_hoglin() {
|
||||
);
|
||||
}
|
||||
|
||||
fn hurt_time(game: &mut Game<'_>, uuid: &str) -> i32 {
|
||||
let id = mob_id(game, uuid);
|
||||
game.mobs.iter().find(|m| m.id == id).unwrap().hurt_time
|
||||
}
|
||||
|
||||
/// Vanilla 26.3's hit pen: the hoglin enters the fight activity at the end of
|
||||
/// the tick after the hit (`HoglinAi.updateActivity` follows the brain), hits
|
||||
/// in the next one, and again 41 ticks later. The pig ticks after the hoglin,
|
||||
/// so it reads `HurtTime` 9 on the tick of each hit.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_hoglin_hits_from_its_second_fighting_tick_every_41_ticks() {
|
||||
let mut game = game();
|
||||
step(&mut game, 40);
|
||||
assert!(game.run_command(&format!("damage {HOGLIN} 1 minecraft:mob_attack by {PIG}")));
|
||||
let mut hits = Vec::new();
|
||||
for tick in 1..=90 {
|
||||
step(&mut game, 1);
|
||||
if hurt_time(&mut game, PIG) == 9 {
|
||||
hits.push(tick);
|
||||
}
|
||||
}
|
||||
assert_eq!(hits, [2, 43, 84]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn deferred_stronger_hits_do_not_count_hurt_timers_down_twice() {
|
||||
use horizon_client::mobs::Damage;
|
||||
let mut game = game();
|
||||
let first = mob_id(&mut game, NEAR_HOGLIN);
|
||||
let second = mob_id(&mut game, HOGLIN);
|
||||
let target = mob_id(&mut game, PIG);
|
||||
game.creatures.ticked.extend([first, second, target]);
|
||||
game.creatures.deferred_hits = true;
|
||||
assert!(game.mob_hits_mob(target, 4.0, Damage::mob(first, [0.0; 3])));
|
||||
assert!(game.mob_hits_mob(target, 6.0, Damage::mob(second, [0.0; 3])));
|
||||
let victim = game.mobs.iter().find(|m| m.id == target).unwrap();
|
||||
assert_eq!((victim.hurt_time, victim.invulnerable), (9, 19));
|
||||
assert_eq!(victim.health, 94.0);
|
||||
|
||||
// A stronger hit on the following tick also leaves the already-counted
|
||||
// timers alone rather than decrementing them again.
|
||||
let victim = game.mobs.iter_mut().find(|m| m.id == target).unwrap();
|
||||
victim.hurt_time = 8;
|
||||
victim.invulnerable = 18;
|
||||
assert!(game.mob_hits_mob(target, 8.0, Damage::mob(first, [0.0; 3])));
|
||||
let victim = game.mobs.iter().find(|m| m.id == target).unwrap();
|
||||
assert_eq!((victim.hurt_time, victim.invulnerable), (8, 18));
|
||||
assert_eq!(victim.health, 92.0);
|
||||
}
|
||||
|
||||
/// Added controls (after the frozen baseline above).
|
||||
fn mob_id(game: &mut Game<'_>, uuid: &str) -> u32 {
|
||||
match game.target_with_uuid(horizon_client::save::uuid_from_text(uuid).unwrap()) {
|
||||
|
||||
@@ -82,7 +82,8 @@ fn game() -> Game<'static> {
|
||||
.unwrap();
|
||||
game.world_root = dir;
|
||||
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
|
||||
let _ = horizon_client::structure_blocks::world_loot_data(
|
||||
// The packs' tables for this test's thread only (#14266).
|
||||
let _ = horizon_client::structure_blocks::load_thread_loot_data(
|
||||
&game.options.resources,
|
||||
&game.pack_files(),
|
||||
);
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -309,17 +309,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 +667,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 +874,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;
|
||||
|
||||
Loaded 100 of 413 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user