mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 10:31:38 -10:00
Compare commits
216
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 | ||
|
|
59a74c49ac | ||
|
|
a899c56c45 | ||
|
|
cff772e57a | ||
|
|
f5541f7c45 | ||
|
|
9ce725cca5 | ||
|
|
9528d81b2f | ||
|
|
8df65734b7 | ||
|
|
1b5800c3ab | ||
|
|
2a67bd988c | ||
|
|
66ead25e8d | ||
|
|
938dcd673c | ||
|
|
f7c7e37999 | ||
|
|
c8d9a748ac | ||
|
|
aa25cc6cb3 | ||
|
|
c06d152243 | ||
|
|
7373b8cf73 | ||
|
|
68ad338029 | ||
|
|
f888d26173 | ||
|
|
c9cf9cb18b | ||
|
|
33754abe21 | ||
|
|
5a67941d3f | ||
|
|
62ed49d4a4 | ||
|
|
9482ae7472 | ||
|
|
b0074207ca | ||
|
|
58abbd0e4a | ||
|
|
e6bc09eeb9 | ||
|
|
3ea0ce07a2 | ||
|
|
07a82eccf3 | ||
|
|
0bdc77efb6 | ||
|
|
3914d39e80 | ||
|
|
021bbb8225 | ||
|
|
4da0ea5197 | ||
|
|
41d1c3adfe | ||
|
|
df7437d02b | ||
|
|
622eda982a | ||
|
|
cdc8772455 | ||
|
|
d289ee5de2 | ||
|
|
3a5bc7ef94 | ||
|
|
7725516a3e | ||
|
|
577c82ffd3 | ||
|
|
401373bc22 | ||
|
|
1324281e8b | ||
|
|
2f1ad94bf7 | ||
|
|
a0f6aeafce | ||
|
|
2ec731aadf | ||
|
|
de08483605 | ||
|
|
81b88d62be | ||
|
|
ace3563955 | ||
|
|
28214f02a7 | ||
|
|
5bb1789373 | ||
|
|
4c593f0ca7 | ||
|
|
ac90e00807 | ||
|
|
8f2b122754 | ||
|
|
cbd47615bc | ||
|
|
04822910b5 | ||
|
|
070902e9f5 | ||
|
|
6c4df147a7 | ||
|
|
3f044a9aca | ||
|
|
e749b17104 | ||
|
|
1340e54376 | ||
|
|
5ae9f06129 | ||
|
|
13061c109f | ||
|
|
40eb5c7fd7 | ||
|
|
04a8acc5a3 | ||
|
|
add5b203c6 | ||
|
|
a93975ceb5 | ||
|
|
3ef15455e3 | ||
|
|
ec08a90d43 | ||
|
|
c4a108b5c3 | ||
|
|
4628acd092 | ||
|
|
71cf748116 |
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
|
||||
@@ -42,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;
|
||||
@@ -54,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.
|
||||
@@ -103,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.
|
||||
@@ -159,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
|
||||
|
||||
@@ -222,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
-46
@@ -706,25 +706,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
@@ -945,12 +926,6 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -1579,7 +1554,6 @@ dependencies = [
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rayon",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1973,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",
|
||||
@@ -3690,26 +3665,6 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rc2"
|
||||
version = "0.8.1"
|
||||
|
||||
+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`.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -27,9 +27,6 @@ horizon-mcp = { path = "../horizon-mcp" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
|
||||
# once, not for every phase of every tick.
|
||||
rayon = "1.10"
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -1208,7 +1208,6 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod bed_geometry;
|
||||
pub mod block_contact;
|
||||
pub mod parallel;
|
||||
pub mod powder_snow;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, autotune, ban_notice, bidi, biome, block_edit,
|
||||
|
||||
@@ -84,9 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
mod obstacles;
|
||||
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
|
||||
pub use obstacles::ObstacleGrid;
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_motion_tests {
|
||||
@@ -1113,6 +1111,110 @@ fn lava_cauldron_contacts(
|
||||
}
|
||||
contacts
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
/// Each box's entity's `getRootVehicle` when it rides something; empty
|
||||
/// when none was given.
|
||||
roots: Vec<Option<u32>>,
|
||||
found: Vec<u32>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
Self::with_roots(boxes, Vec::new())
|
||||
}
|
||||
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
|
||||
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
|
||||
/// mover's own riders out.
|
||||
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
|
||||
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
cells,
|
||||
boxes,
|
||||
roots,
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
self.near_except(area, None, out);
|
||||
}
|
||||
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
|
||||
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
|
||||
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
|
||||
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
|
||||
let (lo, hi) = Self::span(area);
|
||||
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
|
||||
let cells = side(lo.0, hi.0)
|
||||
.saturating_mul(side(lo.1, hi.1))
|
||||
.saturating_mul(side(lo.2, hi.2));
|
||||
// An area over more cells than there are boxes (a stand loaded with a
|
||||
// huge Motion asks for one millions of blocks long): test each box
|
||||
// once rather than walk the cells.
|
||||
if cells > self.boxes.len() as i64 {
|
||||
out.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, b)| kept(i) && b.intersects(area))
|
||||
.map(|(_, b)| *b),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.found.clear();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
self.found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.found.sort_unstable();
|
||||
self.found.dedup();
|
||||
for &i in &self.found {
|
||||
let b = self.boxes[i as usize];
|
||||
if kept(i as usize) && b.intersects(area) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
|
||||
/// search around an item. Holds indexes into the tick's item list.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
//! The boxes that stop a dropped item, by cell (`ObstacleGrid`).
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
/// Each box's entity's `getRootVehicle` when it rides something; empty
|
||||
/// when none was given.
|
||||
roots: Vec<Option<u32>>,
|
||||
found: Vec<u32>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
Self::with_roots(boxes, Vec::new())
|
||||
}
|
||||
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
|
||||
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
|
||||
/// mover's own riders out.
|
||||
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
|
||||
let mut cells: FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
cells,
|
||||
boxes,
|
||||
roots,
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
self.near_except(area, None, out);
|
||||
}
|
||||
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
|
||||
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
|
||||
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
|
||||
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
|
||||
let mut found = std::mem::take(&mut self.found);
|
||||
self.near_with(area, vehicle, &mut found, out);
|
||||
self.found = found;
|
||||
}
|
||||
/// [`Self::near`] with the caller's scratch, so several threads can ask one grid.
|
||||
pub fn near_shared(&self, area: Aabb, found: &mut Vec<u32>, out: &mut Vec<Aabb>) {
|
||||
self.near_with(area, None, found, out);
|
||||
}
|
||||
fn near_with(
|
||||
&self,
|
||||
area: Aabb,
|
||||
vehicle: Option<u32>,
|
||||
found: &mut Vec<u32>,
|
||||
out: &mut Vec<Aabb>,
|
||||
) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
|
||||
let (lo, hi) = Self::span(area);
|
||||
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
|
||||
let cells = side(lo.0, hi.0)
|
||||
.saturating_mul(side(lo.1, hi.1))
|
||||
.saturating_mul(side(lo.2, hi.2));
|
||||
// An area over more cells than there are boxes (a stand loaded with a
|
||||
// huge Motion asks for one millions of blocks long): test each box
|
||||
// once rather than walk the cells.
|
||||
if cells > self.boxes.len() as i64 {
|
||||
out.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, b)| kept(i) && b.intersects(area))
|
||||
.map(|(_, b)| *b),
|
||||
);
|
||||
return;
|
||||
}
|
||||
found.clear();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
for &i in found.iter() {
|
||||
let b = self.boxes[i as usize];
|
||||
if kept(i as usize) && b.intersects(area) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -8482,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,
|
||||
@@ -12462,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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -239,19 +239,6 @@ impl Game<'_> {
|
||||
self.tick_scheduled()?;
|
||||
}
|
||||
self.with_level(|b, level, ticks| {
|
||||
// The masks the random ticks will read, built on helper threads first while the
|
||||
// parallel tick is on (`HORIZON_PARALLEL_TICK`): the draws and ticks don't depend on them.
|
||||
if !debug && horizon_client::parallel::lanes() > 1 {
|
||||
horizon_client::spans::time("random_tick_masks", || {
|
||||
let chunks = &level.world.chunks;
|
||||
level.random_tick_sections.prepare_masks(
|
||||
&scratch.ticking,
|
||||
|key| chunks.get(&key).cloned(),
|
||||
level.facts,
|
||||
&|id| b.random_tick_candidate(BlockStateId(id)),
|
||||
);
|
||||
});
|
||||
}
|
||||
horizon_client::spans::time("random_ticks", || {
|
||||
for (x, z) in scratch.ticking.drain(..).filter(|_| !debug) {
|
||||
// The last answer, asked from Y `from` at a world revision:
|
||||
|
||||
@@ -682,14 +682,19 @@ impl Game<'_> {
|
||||
/// of it (`ClipContext.Block.FALLDAMAGE_RESETTING`, `ClipContext.Fluid.WATER`)
|
||||
/// and a hit resets the fall, before the move's own drop counts.
|
||||
pub(super) fn vehicle_fall_reset(&self, fall: f64, from: Vec3, moved: Vec3) -> f64 {
|
||||
fall_reset(
|
||||
let length = moved.length_squared();
|
||||
if fall == 0.0 || length < 1.0 {
|
||||
return fall;
|
||||
}
|
||||
let reset = &self.res.motion().fall_reset;
|
||||
let hit = horizon_client::entities::fall_reset_hit(
|
||||
&self.world,
|
||||
self.res.motion(),
|
||||
self.res.facts(),
|
||||
fall,
|
||||
&self.res.motion().fluids,
|
||||
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
|
||||
from,
|
||||
moved,
|
||||
)
|
||||
);
|
||||
if hit { 0.0 } else { fall }
|
||||
}
|
||||
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
|
||||
/// `b` already where it landed): on the ground the fall resets; a move
|
||||
@@ -2077,26 +2082,3 @@ mod tests {
|
||||
}
|
||||
}
|
||||
horizon_client::impl_joined!(Boat);
|
||||
|
||||
/// [`Game::vehicle_fall_reset`] from the tables it reads.
|
||||
pub(super) fn fall_reset(
|
||||
world: &horizon_client::world::World,
|
||||
motion: &horizon_client::motion::MotionResources,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
fall: f64,
|
||||
from: Vec3,
|
||||
moved: Vec3,
|
||||
) -> f64 {
|
||||
if fall == 0.0 || moved.length_squared() < 1.0 {
|
||||
return fall;
|
||||
}
|
||||
let reset = &motion.fall_reset;
|
||||
let hit = horizon_client::entities::fall_reset_hit(
|
||||
world,
|
||||
&motion.fluids,
|
||||
|p| reset.contains(&*facts.get(world.block(p.x, p.y, p.z)).name),
|
||||
from,
|
||||
moved,
|
||||
);
|
||||
if hit { 0.0 } else { fall }
|
||||
}
|
||||
@@ -737,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)
|
||||
{
|
||||
@@ -380,7 +399,7 @@ impl Game<'_> {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::lan::mob_meta(
|
||||
m,
|
||||
hub.sync_data(),
|
||||
hub,
|
||||
None,
|
||||
g.world.game_time,
|
||||
&mut meta,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -1424,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(
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod bred_draws;
|
||||
mod mob_geometry;
|
||||
mod mob_group;
|
||||
mod prep;
|
||||
mod projectile_world;
|
||||
mod pufferfish_player;
|
||||
mod spawn_eggs;
|
||||
@@ -125,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`).
|
||||
@@ -1051,11 +1051,18 @@ impl Game<'_> {
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.items()),
|
||||
lid_openers: &self.lid_openers,
|
||||
mod_effects: self.remote.is_none().then_some(self as _),
|
||||
mod_effects: if self.remote.is_none() {
|
||||
Some(self)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// A warden's away suspects are UUIDs: on the server side, where the
|
||||
// entity UUIDs live, they resolve back to the mobs that are here.
|
||||
who_lookup: self.remote.is_none().then_some(self as _),
|
||||
foreign: None,
|
||||
who_lookup: if self.remote.is_none() {
|
||||
Some(self)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
border: Some(self.border.geometry()),
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
@@ -1141,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.
|
||||
@@ -1161,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();
|
||||
@@ -1183,32 +1193,50 @@ impl Game<'_> {
|
||||
self.init_sulfur_attribute_maps();
|
||||
let uuid = self.level.player_uuid;
|
||||
let level_id = self.dim.id();
|
||||
// Each mob's climbing, refreshed, and its snapshot entry (creatures/prep.rs).
|
||||
// LivingEntity.isPushable reads onClimbable live; a mob's cached one
|
||||
// is only refreshed by its own push step, so one that has not ticked
|
||||
// yet (the first tick after a load, a fresh spawn) or has moved since
|
||||
// would be pushed out of a ladder or scaffolding. Refreshed here, from
|
||||
// where each mob stands now, before the snapshot others push it by.
|
||||
let climbing: Vec<bool> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
self.mobs.iter().map(|m| m.on_climbable(&w)).collect()
|
||||
};
|
||||
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
|
||||
m.on_climbable = climbing;
|
||||
}
|
||||
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
|
||||
self.prepare_mobs(uuid, &mut snapshot);
|
||||
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
|
||||
let mut events = std::mem::take(&mut self.creatures.event_buf);
|
||||
events.clear();
|
||||
// SulfurCube.primeWhenOnPoweredPosition runs before super.tick adopts
|
||||
// the body: the cubes whose adopted body explodes, as this tick starts.
|
||||
let primable = self.sulfur_cubes_adopted_explosive();
|
||||
let mobs = std::mem::take(&mut self.mobs);
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
// Each mob's target as the mods last heard it, by its place in `mobs`
|
||||
// (`native_living_change_target`).
|
||||
let mut targets = std::mem::take(&mut self.creatures.target_buf);
|
||||
targets.clear();
|
||||
targets.extend(mobs.iter().map(|m| m.ai.target));
|
||||
let died = std::mem::take(&mut self.died);
|
||||
let mut dead = Vec::new();
|
||||
let mut corpses = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
let mut ticked = std::mem::take(&mut self.creatures.ticked);
|
||||
ticked.clear();
|
||||
let ticking = self.entity_ticking_area();
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
let mut guests = self.lan_guest_views();
|
||||
// What stands in a guest's slot while a mob holds that guest's view for
|
||||
// its tick (see `lent` below).
|
||||
let mut spare = guests.first().map(|g| g.1.clone());
|
||||
let players = self.level_players();
|
||||
let nearest = super::natural_spawning::NearestPlayers::new(&players);
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
let guests = self.lan_guest_views();
|
||||
let bindings = match self.lan.as_mut() {
|
||||
let mut bindings = match self.lan.as_mut() {
|
||||
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The guests' entity ids by connection, for the shulker bullets fired
|
||||
// at the guest a mob sees.
|
||||
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
|
||||
Some(host) if !guests.is_empty() => host
|
||||
.hub
|
||||
@@ -1218,9 +1246,23 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
// The block writes since the last tick: mobs re-path around them.
|
||||
let path_writes = std::mem::take(self.world.path_writes.get_or_insert_with(Vec::new));
|
||||
let group = {
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height, split).
|
||||
// 🤖 Agent: the last field is the grown slime, magma cube or sulfur
|
||||
// cube the body leaves behind (`AbstractCubeMob.remove`), captured as
|
||||
// the body goes because it has left the list by the time it splits.
|
||||
let mut gone = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
// The level random, lent to the mobs (brain behaviours draw from
|
||||
// it) and taken back after their ticks.
|
||||
let mut scratch = self.world.random.clone();
|
||||
let mut pushes = horizon_client::mobs::PendingPushes::default();
|
||||
let mut keep = Vec::with_capacity(mobs.len());
|
||||
let mut grid =
|
||||
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
|
||||
// All but the snapshot is the same for every mob of the tick, so
|
||||
// build it once: the player view and the teams scan every UUID.
|
||||
self.leash_boats = self
|
||||
@@ -1230,52 +1272,87 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let mut base = self.mob_world_base(&[]);
|
||||
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
let mut index = horizon_client::fast_hash::FxHashMap::with_capacity_and_hasher(
|
||||
snapshot.len(),
|
||||
Default::default(),
|
||||
);
|
||||
let mut villager_slots = Vec::new();
|
||||
let mut solid_slots = Vec::new();
|
||||
for (i, s) in snapshot.iter().enumerate() {
|
||||
index.entry(s.id).or_insert(i as u32);
|
||||
if s.species == horizon_client::mobs::Species::Villager {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
if s.may_block_mobs() {
|
||||
solid_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
laps.lap("creature_prepare");
|
||||
let single_player = horizon_client::mobs::single_player_pos(
|
||||
players.iter().map(|p| (p.position, p.spectator)),
|
||||
);
|
||||
let nearest = prep::MobsNearest::new(&nearest, &mobs, single_player.is_none());
|
||||
let mut despawn_w = base.despawn_view();
|
||||
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
|
||||
let shared = mob_group::Shared {
|
||||
uuid,
|
||||
level_id,
|
||||
path_changes: &path_changes,
|
||||
guest_ids: &guest_ids,
|
||||
};
|
||||
let start = mob_group::Start {
|
||||
events,
|
||||
// The level random, lent to the mobs and taken back after their ticks.
|
||||
random: self.world.random.clone(),
|
||||
guests,
|
||||
player: base.player.take(),
|
||||
bindings,
|
||||
died,
|
||||
ticked,
|
||||
targets,
|
||||
};
|
||||
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
|
||||
for i in 0..group.mobs.len() {
|
||||
let position = group.mobs[i].position;
|
||||
let (bx, bz) = (position.x.floor() as i32, position.z.floor() as i32);
|
||||
let seen_guest = group.bind_guest(i);
|
||||
for i in 0..mobs.len() {
|
||||
let m = &mut mobs[i];
|
||||
let (bx, bz) = (m.position.x.floor() as i32, m.position.z.floor() as i32);
|
||||
let seen_guest = if guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let bound = bindings.get(&m.id).copied();
|
||||
let conn = super::lan_mobs::player_for(m, base.player.as_ref(), &guests, bound);
|
||||
match conn {
|
||||
Some(conn) => {
|
||||
bindings.insert(m.id, conn);
|
||||
}
|
||||
None => {
|
||||
bindings.remove(&m.id);
|
||||
}
|
||||
}
|
||||
conn
|
||||
};
|
||||
if !base.world.loaded(bx, bz) {
|
||||
group.unloaded(i);
|
||||
keep.push(!m.hostile() || m.persistent);
|
||||
continue;
|
||||
}
|
||||
let nearest_player = single_player.unwrap_or_else(|| nearest.at(i, position));
|
||||
let nearest_player = single_player.unwrap_or_else(|| {
|
||||
nearest
|
||||
.nearest(m.position.x, m.position.y, m.position.z)
|
||||
.map(|p| p.position)
|
||||
});
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick.
|
||||
// distance) tick. The despawn check reads the nearest player
|
||||
// and the difficulty, not the player the mob sees.
|
||||
if !ticking(bx, bz) {
|
||||
despawn_w.nearest_player = nearest_player;
|
||||
group.rest(i, &despawn_w);
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &despawn_w);
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
}
|
||||
keep.push(kept);
|
||||
continue;
|
||||
}
|
||||
let controlling = group.controlling(i);
|
||||
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
base.player
|
||||
.as_ref()
|
||||
.filter(|p| p.riding_vehicle == Some(m.id))
|
||||
.map(|p| p.velocity)
|
||||
.or_else(|| {
|
||||
guests
|
||||
.iter()
|
||||
.find(|(_, p)| p.riding_vehicle == Some(m.id))
|
||||
.map(|(_, p)| p.velocity)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
// A mod canceled its tick: it stays as it is.
|
||||
if round.canceled(group.mobs[i].id) {
|
||||
group.keep.push(true);
|
||||
if round.canceled(m.id) {
|
||||
keep.push(true);
|
||||
continue;
|
||||
}
|
||||
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
|
||||
@@ -1285,34 +1362,175 @@ impl Game<'_> {
|
||||
Some((key, difficulty)) if key == chunk_key => difficulty,
|
||||
_ => {
|
||||
let difficulty =
|
||||
self.local_difficulty([position.x, position.y, position.z]);
|
||||
self.local_difficulty([m.position.x, m.position.y, m.position.z]);
|
||||
cached_difficulty = Some((chunk_key, difficulty));
|
||||
difficulty
|
||||
}
|
||||
};
|
||||
let player = (seen_guest, nearest_player, controlling);
|
||||
group.tick(i, player, difficulty, &base, &shared);
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
// The guest's view is lent to the mob for its tick, not copied:
|
||||
// the tick only reads it (`&MobWorld`), and it goes back to the
|
||||
// guest's slot straight after.
|
||||
let lent = seen_guest.and_then(|conn| guests.iter().position(|g| g.0 == conn));
|
||||
let active_player = match (seen_guest, lent) {
|
||||
(None, _) => base.player.take(),
|
||||
(Some(_), Some(k)) => spare
|
||||
.take()
|
||||
.map(|stand_in| std::mem::replace(&mut guests[k].1, stand_in)),
|
||||
(Some(_), None) => None,
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
others_solid: Some(&solid_slots),
|
||||
difficulty,
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
ticked.insert(mobs[i].id);
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled = super::tick_profile::start().map(|t| (t, mobs[i].kind.clone()));
|
||||
let kept =
|
||||
horizon_client::mobs::riders::tick(&mut mobs, i, &w, &mut scratch, &mut events);
|
||||
if let Some((started, kind)) = sampled {
|
||||
super::tick_profile::add(level_id, &kind, started);
|
||||
}
|
||||
let m = &mut mobs[i];
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
} else if let Some(k) = lent {
|
||||
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
|
||||
}
|
||||
// Its tick changed its target: the mods may keep the one it had
|
||||
// (`native_living_change_target`), and then what the tick did to
|
||||
// the new one is not dealt.
|
||||
if let Some(&before) = targets.get(i)
|
||||
&& m.ai.target != before
|
||||
{
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Self::native_living_change_target(
|
||||
level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
if let Some(new) = m.ai.target {
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if hits_target(&events[k], new) {
|
||||
events.remove(k);
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
targets[i] = m.ai.target;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
corpses.push(m.clone());
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
// 🤖 Agent: `AbstractCubeMob.remove` splits a grown cube into
|
||||
// its smaller halves when it is removed, twenty ticks after
|
||||
// the death. The parent leaves the list before the split
|
||||
// runs, so its size, position, kind and persistence are
|
||||
// captured here, as they are at the removal.
|
||||
let split = if matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::SulfurCube
|
||||
) && m.extra.size > 1
|
||||
{
|
||||
Some(m.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
gone.push((m.id, m.position, width, height, split));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Some(Who::Player)
|
||||
{
|
||||
*target = Some(Who::Mob(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if super::lan_mobs::acts_on_player(&events[k]) {
|
||||
guest_events.push((conn, m.id, events.remove(k)));
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Killed during its own tick (drowning, burning, falling...).
|
||||
if !m.alive() && !m.removed && died.insert(m.id) {
|
||||
dead.push(m.clone());
|
||||
}
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
}
|
||||
keep.push(kept);
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
snapshot[i] = seen(m, uuid);
|
||||
snapshot[i].alive &= kept;
|
||||
grid.update(i, snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
}
|
||||
// Targets changed after the mob's own tick (another mob's alert):
|
||||
// heard now, and a cancel puts the old one back before its next tick.
|
||||
for (i, m) in group.mobs.iter_mut().enumerate() {
|
||||
let Some(&before) = group.targets.get(i) else {
|
||||
for (i, m) in mobs.iter_mut().enumerate() {
|
||||
let Some(&before) = targets.get(i) else {
|
||||
break;
|
||||
};
|
||||
if m.ai.target == before {
|
||||
continue;
|
||||
}
|
||||
let seen_guest = if group.guests.is_empty() {
|
||||
let seen_guest = if guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
group.bindings.get(&m.id).copied()
|
||||
bindings.get(&m.id).copied()
|
||||
};
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&group.guests,
|
||||
&guests,
|
||||
&guest_ids,
|
||||
group.player.as_ref(),
|
||||
base.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
@@ -1331,29 +1549,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
laps.lap("creature_loop");
|
||||
let mut flags = std::mem::take(&mut group.keep).into_iter();
|
||||
group.mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
for m in &mut group.mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut group.pushes);
|
||||
let mut flags = keep.into_iter();
|
||||
mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
for m in &mut mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
}
|
||||
group
|
||||
};
|
||||
let mob_group::Group {
|
||||
mut mobs,
|
||||
mut snapshot,
|
||||
mut events,
|
||||
random: level_random,
|
||||
guests,
|
||||
mut bindings,
|
||||
died,
|
||||
ticked,
|
||||
mut targets,
|
||||
dead,
|
||||
corpses,
|
||||
gone,
|
||||
guest_events,
|
||||
..
|
||||
} = group;
|
||||
level_random = scratch;
|
||||
}
|
||||
self.world.random = level_random;
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& !guests.is_empty()
|
||||
@@ -1369,7 +1571,7 @@ impl Game<'_> {
|
||||
// first, as `append` put them; with none, the ticked list goes back
|
||||
// as it is instead of being copied into a new one, every mob a tick.
|
||||
if self.mobs.is_empty() {
|
||||
self.mobs = mobs.into();
|
||||
self.mobs = mobs;
|
||||
} else {
|
||||
self.mobs.append(&mut mobs);
|
||||
}
|
||||
@@ -1413,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);
|
||||
@@ -1927,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()
|
||||
@@ -1949,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.
|
||||
@@ -2026,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
|
||||
@@ -8171,7 +8354,6 @@ mod pearl_tests {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
//! The mob loop of `tick_creature_entities` over a group of mobs, in their list order, with
|
||||
//! everything the loop keeps for them: their snapshot and its indexes, the pushes and events
|
||||
//! their ticks give, the level random they draw from, the players they see and what dies. The
|
||||
//! serial loop is one group holding every mob; a group of the parallel mob tick (#14457) holds
|
||||
//! the mobs no mob outside it can reach this tick.
|
||||
use super::*;
|
||||
use horizon_client::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
mobs::{MobSlot, PendingPushes, seen_grid::SeenGrid, spawning::LocalDifficulty},
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
/// What every mob of the loop reads and none writes.
|
||||
pub(super) struct Shared<'a> {
|
||||
pub uuid: Option<[i32; 4]>,
|
||||
pub level_id: &'static str,
|
||||
/// The blocks whose collision changed since the last tick: mobs re-path around them.
|
||||
pub path_changes: &'a [[i32; 3]],
|
||||
/// The guests' entity ids by connection, for the shulker bullets fired at the guest a mob
|
||||
/// sees.
|
||||
pub guest_ids: &'a [(u32, u32)],
|
||||
}
|
||||
|
||||
/// A mob whose body goes this tick: (id, feet, width, height, the grown cube it splits into).
|
||||
/// The last field is the grown slime, magma cube or sulfur cube the body leaves behind
|
||||
/// (`AbstractCubeMob.remove`), captured as the body goes because it has left the list by the
|
||||
/// time it splits.
|
||||
pub(super) type Gone = (u32, Vec3, f64, f64, Option<Mob>);
|
||||
|
||||
/// A group's mobs and everything the loop keeps for them, in their list order.
|
||||
pub(super) struct Group<M: MobSlot> {
|
||||
pub mobs: Vec<M>,
|
||||
/// Each mob's snapshot entry: as it was before the tick until its turn, as its turn left it
|
||||
/// after.
|
||||
pub snapshot: Vec<Seen>,
|
||||
grid: SeenGrid,
|
||||
/// The first slot of each id, as a scan would find it.
|
||||
index: FxHashMap<u32, u32>,
|
||||
villager_slots: Vec<u32>,
|
||||
solid_slots: Vec<u32>,
|
||||
pub pushes: PendingPushes,
|
||||
pub events: Vec<MobEvent>,
|
||||
/// The level random, lent to the mobs (brain behaviours draw from it).
|
||||
pub random: LegacyRandom,
|
||||
pub guests: super::super::lan_mobs::GuestViews,
|
||||
/// What stands in a guest's slot while a mob holds that guest's view for its tick.
|
||||
spare: Option<PlayerView>,
|
||||
/// The host's own player, lent to each mob that sees it for its tick.
|
||||
pub player: Option<PlayerView>,
|
||||
/// The guest each mob sees, by mob id.
|
||||
pub bindings: FxHashMap<u32, u32>,
|
||||
pub died: FxHashSet<u32>,
|
||||
pub ticked: FxHashSet<u32>,
|
||||
/// Each mob's target as the mods last heard it (`native_living_change_target`).
|
||||
pub targets: Vec<Option<Who>>,
|
||||
/// Whether each mob stays in the list, pushed at its turn.
|
||||
pub keep: Vec<bool>,
|
||||
pub dead: Vec<Mob>,
|
||||
pub corpses: Vec<Mob>,
|
||||
pub gone: Vec<Gone>,
|
||||
pub guest_events: Vec<(u32, u32, MobEvent)>,
|
||||
}
|
||||
|
||||
/// What a group starts from besides its mobs and their snapshot.
|
||||
pub(super) struct Start {
|
||||
pub events: Vec<MobEvent>,
|
||||
pub random: LegacyRandom,
|
||||
pub guests: super::super::lan_mobs::GuestViews,
|
||||
pub player: Option<PlayerView>,
|
||||
pub bindings: FxHashMap<u32, u32>,
|
||||
pub died: FxHashSet<u32>,
|
||||
pub ticked: FxHashSet<u32>,
|
||||
pub targets: Vec<Option<Who>>,
|
||||
}
|
||||
|
||||
impl<M: MobSlot> Group<M> {
|
||||
/// A group of `mobs`, whose snapshot entries are `snapshot`.
|
||||
pub(super) fn new(mobs: Vec<M>, snapshot: Vec<Seen>, start: Start) -> Self {
|
||||
let grid = SeenGrid::new(snapshot.iter().map(|s| s.bounds));
|
||||
let mut index = FxHashMap::with_capacity_and_hasher(snapshot.len(), Default::default());
|
||||
let (mut villager_slots, mut solid_slots) = (Vec::new(), Vec::new());
|
||||
for (i, s) in snapshot.iter().enumerate() {
|
||||
index.entry(s.id).or_insert(i as u32);
|
||||
if s.species == Species::Villager {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
if s.may_block_mobs() {
|
||||
solid_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
let n = mobs.len();
|
||||
Self {
|
||||
spare: start.guests.first().map(|g| g.1.clone()),
|
||||
mobs,
|
||||
snapshot,
|
||||
grid,
|
||||
index,
|
||||
villager_slots,
|
||||
solid_slots,
|
||||
pushes: PendingPushes::default(),
|
||||
events: start.events,
|
||||
random: start.random,
|
||||
guests: start.guests,
|
||||
player: start.player,
|
||||
bindings: start.bindings,
|
||||
died: start.died,
|
||||
ticked: start.ticked,
|
||||
targets: start.targets,
|
||||
keep: Vec::with_capacity(n),
|
||||
dead: Vec::new(),
|
||||
corpses: Vec::new(),
|
||||
gone: Vec::new(),
|
||||
guest_events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The guest mob `i` sees now, kept in the bindings: none while no guest is on.
|
||||
pub(super) fn bind_guest(&mut self, i: usize) -> Option<u32> {
|
||||
if self.guests.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let m = self.mobs[i].mob();
|
||||
let bound = self.bindings.get(&m.id).copied();
|
||||
let conn = super::super::lan_mobs::player_for(m, self.player.as_ref(), &self.guests, bound);
|
||||
match conn {
|
||||
Some(conn) => self.bindings.insert(m.id, conn),
|
||||
None => self.bindings.remove(&m.id),
|
||||
};
|
||||
conn
|
||||
}
|
||||
|
||||
/// Mob `i`'s turn when its chunk isn't loaded: it is kept as it is, unless it would despawn.
|
||||
pub(super) fn unloaded(&mut self, i: usize) {
|
||||
let m = self.mobs[i].mob();
|
||||
self.keep.push(!m.hostile() || m.persistent);
|
||||
}
|
||||
|
||||
/// Mob `i`'s turn outside the entity-ticking area: only its despawn check, which reads the
|
||||
/// nearest player and the difficulty, not the player the mob sees.
|
||||
pub(super) fn rest(&mut self, i: usize, despawn_w: &MobWorld) {
|
||||
let m = self.mobs[i].mob_mut();
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, despawn_w);
|
||||
if !kept {
|
||||
self.died.remove(&m.id);
|
||||
self.snapshot[i].alive = false;
|
||||
}
|
||||
self.keep.push(kept);
|
||||
}
|
||||
|
||||
/// Who controls mob `i`'s moves this tick: the velocity of the player riding it, when it is
|
||||
/// a controlled mount.
|
||||
pub(super) fn controlling(&self, i: usize) -> Option<Vec3> {
|
||||
let m = self.mobs[i].mob();
|
||||
horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
self.player
|
||||
.as_ref()
|
||||
.filter(|p| p.riding_vehicle == Some(m.id))
|
||||
.map(|p| p.velocity)
|
||||
.or_else(|| {
|
||||
self.guests
|
||||
.iter()
|
||||
.find(|(_, p)| p.riding_vehicle == Some(m.id))
|
||||
.map(|(_, p)| p.velocity)
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Mob `i`'s own tick, then what the loop does with what it did. `players`: the guest it
|
||||
/// sees (none: the host's player), its nearest player, and the velocity of the player
|
||||
/// controlling it as a mount ([`Self::controlling`]).
|
||||
pub(super) fn tick(
|
||||
&mut self,
|
||||
i: usize,
|
||||
(seen_guest, nearest_player, controlling_player_movement): (
|
||||
Option<u32>,
|
||||
Option<Vec3>,
|
||||
Option<Vec3>,
|
||||
),
|
||||
difficulty: LocalDifficulty,
|
||||
base: &MobWorld,
|
||||
shared: &Shared,
|
||||
) {
|
||||
let m = self.mobs[i].mob_mut();
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut self.pushes);
|
||||
horizon_client::mobs::blocks_changed(m, shared.path_changes);
|
||||
let from = self.events.len();
|
||||
// The guest's view is lent to the mob for its tick, not copied:
|
||||
// the tick only reads it (`&MobWorld`), and it goes back to the
|
||||
// guest's slot straight after.
|
||||
let lent = seen_guest.and_then(|conn| self.guests.iter().position(|g| g.0 == conn));
|
||||
let active_player = match (seen_guest, lent) {
|
||||
(None, _) => self.player.take(),
|
||||
(Some(_), Some(k)) => self
|
||||
.spare
|
||||
.take()
|
||||
.map(|stand_in| std::mem::replace(&mut self.guests[k].1, stand_in)),
|
||||
(Some(_), None) => None,
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &self.snapshot,
|
||||
others_grid: Some(&self.grid),
|
||||
others_index: Some(&self.index),
|
||||
others_villagers: Some(&self.villager_slots),
|
||||
others_solid: Some(&self.solid_slots),
|
||||
difficulty,
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..*base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
self.ticked.insert(self.mobs[i].mob().id);
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled =
|
||||
super::super::tick_profile::start().map(|t| (t, self.mobs[i].mob().kind.clone()));
|
||||
let kept = horizon_client::mobs::riders::tick(
|
||||
&mut self.mobs,
|
||||
i,
|
||||
&w,
|
||||
&mut self.random,
|
||||
&mut self.events,
|
||||
);
|
||||
if let Some((started, kind)) = sampled {
|
||||
super::super::tick_profile::add(shared.level_id, &kind, started);
|
||||
}
|
||||
let m = self.mobs[i].mob_mut();
|
||||
if seen_guest.is_none() {
|
||||
self.player = w.player;
|
||||
} else if let Some(k) = lent {
|
||||
self.spare = w
|
||||
.player
|
||||
.map(|v| std::mem::replace(&mut self.guests[k].1, v));
|
||||
}
|
||||
// Its tick changed its target: the mods may keep the one it had
|
||||
// (`native_living_change_target`), and then what the tick did to
|
||||
// the new one is not dealt.
|
||||
if let Some(&before) = self.targets.get(i)
|
||||
&& m.ai.target != before
|
||||
{
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&self.guests,
|
||||
shared.guest_ids,
|
||||
self.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Game::native_living_change_target(
|
||||
shared.level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
if let Some(new) = m.ai.target {
|
||||
let mut k = from;
|
||||
while k < self.events.len() {
|
||||
if hits_target(&self.events[k], new) {
|
||||
self.events.remove(k);
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
self.targets[i] = m.ai.target;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
self.corpses.push(m.clone());
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut self.events, from, &mut self.pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
// `AbstractCubeMob.remove` splits a grown cube into its smaller halves when it is
|
||||
// removed, twenty ticks after the death. The parent leaves the list before the split
|
||||
// runs, so its size, position, kind and persistence are captured here, as they are
|
||||
// at the removal.
|
||||
let split = if matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::SulfurCube
|
||||
) && m.extra.size > 1
|
||||
{
|
||||
Some(m.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.gone.push((m.id, m.position, width, height, split));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = shared.guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut self.events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Some(Who::Player)
|
||||
{
|
||||
*target = Some(Who::Mob(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < self.events.len() {
|
||||
if super::super::lan_mobs::acts_on_player(&self.events[k]) {
|
||||
self.guest_events.push((conn, m.id, self.events.remove(k)));
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Killed during its own tick (drowning, burning, falling...).
|
||||
if !m.alive() && !m.removed && self.died.insert(m.id) {
|
||||
self.dead.push(m.clone());
|
||||
}
|
||||
if !kept {
|
||||
self.died.remove(&m.id);
|
||||
}
|
||||
self.keep.push(kept);
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
self.snapshot[i] = seen(m, shared.uuid);
|
||||
self.snapshot[i].alive &= kept;
|
||||
self.grid.update(i, self.snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut self.mobs, &mut self.events, from);
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
//! What the mob loop (`tick_creature_entities`) prepares for every mob before it runs, on the tick's
|
||||
//! helper threads while the parallel tick is on (`horizon_client::parallel`): each answer reads only
|
||||
//! that mob, the blocks and the players, so the helpers give what one thread gives.
|
||||
use super::super::{Game, natural_spawning::NearestPlayers};
|
||||
use horizon_client::mobs::{Mob, MobWorld, Seen};
|
||||
use horizon_client::parallel::par_map;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// From this many mobs a preparation goes to the helper threads.
|
||||
const MIN_MOBS: usize = 256;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.isPushable` reads onClimbable live; a mob's cached one is only refreshed by its
|
||||
/// own push step, so one that has not ticked yet (the first tick after a load, a fresh spawn) or
|
||||
/// has moved since would be pushed out of a ladder or scaffolding. Refreshed here, from where
|
||||
/// each mob stands now; then each mob's entry of the snapshot others push it by goes in
|
||||
/// `snapshot`.
|
||||
pub(super) fn prepare_mobs(&mut self, player: Option<[i32; 4]>, snapshot: &mut Vec<Seen>) {
|
||||
let climbing: Vec<bool> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let rebuild = hookless(&w);
|
||||
par_map(&self.mobs, MIN_MOBS, |part, out| {
|
||||
let w = rebuild();
|
||||
out.extend(part.iter().map(|m| m.on_climbable(&w)));
|
||||
})
|
||||
};
|
||||
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
|
||||
m.on_climbable = climbing;
|
||||
}
|
||||
snapshot.extend(par_map(&self.mobs, MIN_MOBS, |part, out| {
|
||||
out.extend(part.iter().map(|m| super::seen(m, player)));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest player each mob's despawn check reads (`Mob.checkDespawn`), found for every mob before
|
||||
/// the loop. A mob that moved before its turn is asked again where it stands.
|
||||
pub(super) struct MobsNearest<'p> {
|
||||
players: &'p NearestPlayers<'p>,
|
||||
/// Each mob's position as the loop started and its nearest player there; empty while the
|
||||
/// parallel tick is off (every mob asks at its turn).
|
||||
found: Vec<(Vec3, Option<Vec3>)>,
|
||||
}
|
||||
|
||||
impl<'p> MobsNearest<'p> {
|
||||
/// `wanted`: whether the loop asks (not with one player, whose position answers).
|
||||
pub(super) fn new(players: &'p NearestPlayers<'p>, mobs: &[Mob], wanted: bool) -> Self {
|
||||
let found = if wanted && horizon_client::parallel::lanes() > 1 {
|
||||
par_map(mobs, MIN_MOBS, |part, out| {
|
||||
out.extend(
|
||||
part.iter()
|
||||
.map(|m| (m.position, Self::ask(players, m.position))),
|
||||
);
|
||||
})
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Self { players, found }
|
||||
}
|
||||
fn ask(players: &NearestPlayers, at: Vec3) -> Option<Vec3> {
|
||||
players.nearest(at.x, at.y, at.z).map(|p| p.position)
|
||||
}
|
||||
/// The nearest player to mob `i` of the loop, standing at `at`.
|
||||
pub(super) fn at(&self, i: usize, at: Vec3) -> Option<Vec3> {
|
||||
match self.found.get(i) {
|
||||
Some(&(was, nearest)) if was == at => nearest,
|
||||
_ => Self::ask(self.players, at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` without its hooks into the game, which can't leave the server thread, as a recipe a
|
||||
/// helper thread builds its own world from: native mod effects (the phases on helpers don't run
|
||||
/// them) and the UUID lookup.
|
||||
fn hookless<'w>(base: &MobWorld<'w>) -> impl Fn() -> MobWorld<'w> + Sync + use<'w> {
|
||||
// Every field but the two hooks, named, so that a field added to `MobWorld` is decided here.
|
||||
let MobWorld {
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
fluid_tables,
|
||||
movement,
|
||||
collision,
|
||||
suffocation,
|
||||
data,
|
||||
player,
|
||||
controlling_player_movement,
|
||||
nearest_player,
|
||||
others,
|
||||
dragon,
|
||||
leash_boats,
|
||||
others_grid,
|
||||
others_index,
|
||||
others_villagers,
|
||||
others_solid,
|
||||
daylight,
|
||||
monsters_burn,
|
||||
game_time,
|
||||
day_time,
|
||||
raining,
|
||||
thundering,
|
||||
level,
|
||||
block_tags,
|
||||
landing,
|
||||
biomes,
|
||||
mob_griefing,
|
||||
universal_anger,
|
||||
mob_drops,
|
||||
max_entity_cramming,
|
||||
difficulty,
|
||||
dimension_type,
|
||||
world_seed,
|
||||
sea_level,
|
||||
teams,
|
||||
items,
|
||||
lid_openers,
|
||||
mod_effects: _,
|
||||
who_lookup: _,
|
||||
foreign: _,
|
||||
border,
|
||||
} = base.clone();
|
||||
move || MobWorld {
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
fluid_tables,
|
||||
movement,
|
||||
collision,
|
||||
suffocation,
|
||||
data,
|
||||
player: player.clone(),
|
||||
controlling_player_movement,
|
||||
nearest_player,
|
||||
others,
|
||||
dragon,
|
||||
leash_boats,
|
||||
others_grid,
|
||||
others_index,
|
||||
others_villagers,
|
||||
others_solid,
|
||||
daylight,
|
||||
monsters_burn,
|
||||
game_time,
|
||||
day_time,
|
||||
raining,
|
||||
thundering,
|
||||
level,
|
||||
block_tags,
|
||||
landing,
|
||||
biomes,
|
||||
mob_griefing,
|
||||
universal_anger,
|
||||
mob_drops,
|
||||
max_entity_cramming,
|
||||
difficulty,
|
||||
dimension_type,
|
||||
world_seed,
|
||||
sea_level,
|
||||
teams: std::sync::Arc::clone(&teams),
|
||||
items,
|
||||
lid_openers,
|
||||
mod_effects: None,
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
border,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::natural_spawning::LevelPlayer;
|
||||
use super::*;
|
||||
use horizon_core::geometry::Aabb;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn preparation_refreshes_climbing_and_nearest_after_a_move() {
|
||||
let mut game = super::super::super::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for command in [
|
||||
"setblock 8 80 9 stone",
|
||||
"setblock 8 80 8 ladder[facing=north]",
|
||||
"summon pig 8.5 80 8.5 {NoAI:1b}",
|
||||
"summon pig 9.5 80 8.5 {NoAI:1b}",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
assert_eq!(game.mobs.len(), 2);
|
||||
let examples = game.mobs.clone();
|
||||
game.mobs = (0..MIN_MOBS + 37)
|
||||
.map(|i| {
|
||||
let mut mob = examples[i % 2].clone();
|
||||
mob.id = 10_000 + i as u32;
|
||||
mob.on_climbable = i % 2 != 0; // deliberately stale in both cases
|
||||
mob
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
let mut snapshot = Vec::new();
|
||||
game.prepare_mobs(None, &mut snapshot);
|
||||
assert_eq!(snapshot.len(), game.mobs.len());
|
||||
for (i, (m, seen)) in game.mobs.iter().zip(&snapshot).enumerate() {
|
||||
assert_eq!(m.on_climbable, i % 2 == 0, "mob {i}");
|
||||
assert_eq!(seen.id, m.id);
|
||||
assert_eq!(seen.position, m.position);
|
||||
assert_eq!(seen.pushable, i % 2 != 0, "snapshot {i}");
|
||||
}
|
||||
|
||||
let player = |x, spectator| {
|
||||
let position = Vec3::new(x, 80.0, 8.5);
|
||||
LevelPlayer {
|
||||
uuid: 0,
|
||||
position,
|
||||
bounds: Aabb::new(position, position),
|
||||
alive: false, // dead players still count for checkDespawn
|
||||
creative: false,
|
||||
spectator,
|
||||
}
|
||||
};
|
||||
let players = [player(8.5, true), player(7.5, false), player(9.5, false)];
|
||||
let index = NearestPlayers::new(&players);
|
||||
let nearest = MobsNearest::new(&index, &game.mobs, true);
|
||||
for (i, mob) in game.mobs.iter().enumerate() {
|
||||
let expected = if i % 2 == 0 {
|
||||
players[1].position
|
||||
} else {
|
||||
players[2].position
|
||||
};
|
||||
assert_eq!(nearest.at(i, mob.position), Some(expected));
|
||||
// A previous mob's push moved this mob across the nearest-player tie.
|
||||
assert_eq!(
|
||||
nearest.at(i, Vec3::new(10.0, 80.0, 8.5)),
|
||||
Some(players[2].position)
|
||||
);
|
||||
}
|
||||
let no_cache = MobsNearest::new(&index, &game.mobs, false);
|
||||
assert_eq!(
|
||||
no_cache.at(0, game.mobs[0].position),
|
||||
Some(players[1].position)
|
||||
);
|
||||
let spectators = [player(8.5, true)];
|
||||
let index = NearestPlayers::new(&spectators);
|
||||
let nearest = MobsNearest::new(&index, &game.mobs, true);
|
||||
assert_eq!(nearest.at(0, game.mobs[0].position), None);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -614,9 +614,24 @@ impl Game<'_> {
|
||||
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
|
||||
) -> (bool, bool) {
|
||||
let no_physics = escape_speed.is_some();
|
||||
let step = self
|
||||
.item_tables()
|
||||
.move_item(item, escape_speed, entity_boxes, splash);
|
||||
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
// Entity.baseTick splashes before moving, except on the first tick.
|
||||
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
|
||||
item.lava_fall(heights.1 > 0.0);
|
||||
let step = item.tick_move_before_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
no_physics,
|
||||
escape_speed,
|
||||
heights,
|
||||
entity_boxes,
|
||||
Some(self.border.geometry()),
|
||||
Some(&self.res.motion().blocks),
|
||||
);
|
||||
item.check_fall(&step, |fall, from, moved| {
|
||||
self.vehicle_fall_reset(fall, from, moved)
|
||||
});
|
||||
if let Some(pos) = step.vertical_contact {
|
||||
let state = self.world.block(pos.x, pos.y, pos.z);
|
||||
let position = item.position;
|
||||
@@ -635,9 +650,23 @@ impl Game<'_> {
|
||||
return (false, true);
|
||||
}
|
||||
}
|
||||
let alive = self
|
||||
.item_tables()
|
||||
.settle_item(item, no_physics, step, splash);
|
||||
item.finish_move_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
no_physics,
|
||||
Some(&self.res.motion().blocks),
|
||||
step,
|
||||
);
|
||||
item.push_by_bubble_column(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
let alive = item.tick_settle(&self.world, self.res.facts(), no_physics, step);
|
||||
// ItemEntity.tick refreshes immersion and splashes on water entry.
|
||||
let (water, _) = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
item.water_entry(water > 0.0, false, splash);
|
||||
(alive, false)
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -133,44 +133,4 @@ fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entit
|
||||
game.tick_items();
|
||||
assert!(game.dropped.is_empty(), "consumed stack of {count}");
|
||||
}
|
||||
|
||||
// More than the helper threshold, with a native contact hook loaded:
|
||||
// every item must still reach its turn's hook, keeping opaque components.
|
||||
// Unique components prevent merges despite all items sharing a landing.
|
||||
for count in [13, 2, 1] {
|
||||
game.dropped.clear();
|
||||
probe.contacts.lock().unwrap().clear();
|
||||
for n in 0..300 {
|
||||
let mut stack = input.clone();
|
||||
stack.count = count;
|
||||
stack
|
||||
.data_mut()
|
||||
.set_other("fixture:opaque", Some(horizon_world::nbt::Tag::Long(n)));
|
||||
game.spawn_item(stack, [landing[0], 64.1, landing[2]], [0.0, -0.3, 0.0], 20);
|
||||
game.dropped.last_mut().unwrap().ticks = 39;
|
||||
}
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
let before = game.dropped.clone();
|
||||
game.tick_items();
|
||||
let expected_contacts: Vec<_> = before
|
||||
.iter()
|
||||
.map(|item| (false, item.id, [4, 63, 4], landing, count))
|
||||
.collect();
|
||||
assert_eq!(*probe.contacts.lock().unwrap(), expected_contacts);
|
||||
if count <= 2 {
|
||||
assert!(game.dropped.is_empty(), "consumed 300 stacks of {count}");
|
||||
} else {
|
||||
assert_eq!(game.dropped.len(), 300);
|
||||
for (item, old) in game.dropped.iter().zip(&before) {
|
||||
let mut expected = old.stack.clone();
|
||||
expected.count = 11;
|
||||
assert_eq!(item.stack, expected);
|
||||
assert_eq!(item.id, old.id);
|
||||
assert_eq!(item.join_seq(), old.join_seq());
|
||||
assert_eq!(item.position, vanilla.position);
|
||||
assert_eq!(item.velocity, vanilla.velocity);
|
||||
assert_eq!(item.age, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
//! `tick_experience`), the stacks a destroyed container lets fall, and spawning and merging orbs.
|
||||
use super::*;
|
||||
|
||||
mod ahead;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_items(&mut self) {
|
||||
// EntityTickEvent.Pre for the items about to tick, and the Post after them.
|
||||
@@ -45,13 +43,6 @@ impl Game<'_> {
|
||||
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
let mut nearby = Vec::new();
|
||||
let mut bubble_tops = Vec::new();
|
||||
let mut moves = ahead::Moves::work_out(self, &items, &obstacles, round, |item| {
|
||||
!self.joined_during_entity_pass(item.id)
|
||||
&& ticking(
|
||||
item.position.x.floor() as i32,
|
||||
item.position.z.floor() as i32,
|
||||
)
|
||||
});
|
||||
// `ItemUtils.onContainerDestroyed`: what a destroyed item held, and
|
||||
// where it lay, for adding to the level once its own tick is done.
|
||||
let mut destroyed: Vec<(ItemStack, Vec3)> = Vec::new();
|
||||
@@ -74,35 +65,33 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut at_turn = |item: &mut ItemEntity| {
|
||||
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
|
||||
// where the item can reach this tick.
|
||||
obstacles.near(ahead::reach(item), &mut nearby);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
|
||||
// or the border, placed or closed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging
|
||||
&& item.inside_block(
|
||||
&self.world,
|
||||
self.collision,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut entered = Vec::new();
|
||||
let (alive, discarded) = self.tick_item_with_block_contacts(
|
||||
item,
|
||||
escape_speed,
|
||||
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
|
||||
// where the item can reach this tick (a block of slack for the next velocity).
|
||||
let reach = Vec3::new(
|
||||
item.velocity.x.abs() + 1.0,
|
||||
item.velocity.y.abs() + 1.0,
|
||||
item.velocity.z.abs() + 1.0,
|
||||
);
|
||||
obstacles.near(item.bounds().inflate(reach), &mut nearby);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
|
||||
// or the border, placed or closed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging
|
||||
&& item.inside_block(
|
||||
&self.world,
|
||||
self.collision,
|
||||
&nearby,
|
||||
false,
|
||||
&mut |splash| entered.push(splash),
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
(no_physics, alive, discarded, entered)
|
||||
};
|
||||
let (no_physics, alive, discarded, entered) = match moves.take(i, item, &mut at_turn) {
|
||||
Some(moved) => moved,
|
||||
None => at_turn(item),
|
||||
};
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut entered = Vec::new();
|
||||
let (alive, discarded) = self.tick_item_with_block_contacts(
|
||||
item,
|
||||
escape_speed,
|
||||
&nearby,
|
||||
false,
|
||||
&mut |splash| entered.push(splash),
|
||||
);
|
||||
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
|
||||
// to clients). Its addParticle is empty.
|
||||
for splash in entered {
|
||||
@@ -996,14 +985,9 @@ mod escape_tests {
|
||||
let mut game = server_game();
|
||||
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
// Entity-ticking chunks also need the surrounding loaded chunks.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(8, 8));
|
||||
let stone = game
|
||||
@@ -1017,17 +1001,13 @@ mod escape_tests {
|
||||
game.spawn_item(stack, [8.5, 64.4, 8.5], [0.0, 0.0, 0.0], 10);
|
||||
let item = game.dropped.last().unwrap();
|
||||
let expected = f64::from(item.random.clone().next_float() * 0.2 + 0.1);
|
||||
let mut own = item.random.clone();
|
||||
own.next_float();
|
||||
let mut level = game.world.random.clone();
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
game.tick_items();
|
||||
let item = game.dropped.last().unwrap();
|
||||
// Up is the nearest open side from the middle of the block.
|
||||
// The push replaces the fall's velocity; then the 0.98 drag.
|
||||
// Entity.getAirDrag returns 0.98F, promoted to double for the drag.
|
||||
assert_eq!(item.velocity.y, expected * f64::from(0.98_f32));
|
||||
assert_eq!(item.random.clone().next_int(), own.next_int());
|
||||
assert_eq!(item.velocity.y, expected * 0.98);
|
||||
assert_eq!(game.world.random.next_float(), level.next_float());
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
//! The dropped items' own moves, worked out ahead of the item loop on the tick's helper threads
|
||||
//! while the parallel tick is on (`horizon_client::parallel`).
|
||||
//!
|
||||
//! An item's move (`ItemEntity.tick` up to its merge) reads only itself, the level's blocks, the
|
||||
//! obstacle boxes and the border, and the item loop changes none of them: so an item's move worked
|
||||
//! out on a copy before the loop is the move its turn would make. Its escape from inside a block
|
||||
//! draws the item's own random, which the copy carries. A merge before its turn can change its
|
||||
//! count, age and pickup delay: such an item moves at its turn instead. With a native mod loaded
|
||||
//! (its hooks see every item) or a mod listening to the round, every item moves at its turn.
|
||||
use super::super::{boats::fall_reset, entity::Entity as _, native_ticks};
|
||||
use super::*;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
entities::{ItemEntity, ItemSplash, ItemStep, ObstacleGrid},
|
||||
motion::MotionResources,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3, border::BorderGeometry};
|
||||
use horizon_world::{block_collision::BlockCollisionRegistry, registry::BlockRegistry};
|
||||
|
||||
/// From this many items their moves go to the helper threads.
|
||||
const MIN_ITEMS: usize = 256;
|
||||
|
||||
/// What an item's own move reads.
|
||||
pub(in crate::game) struct ItemTables<'a> {
|
||||
world: &'a World,
|
||||
facts: &'a BlockFacts,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
registry: &'a BlockRegistry,
|
||||
border: &'a BorderGeometry,
|
||||
}
|
||||
|
||||
impl<'w> Game<'w> {
|
||||
pub(in crate::game) fn item_tables(&self) -> ItemTables<'_> {
|
||||
ItemTables {
|
||||
world: &self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
motion: self.res.motion(),
|
||||
registry: self.pack.assets.registry,
|
||||
border: self.border.geometry(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemTables<'_> {
|
||||
/// `ItemEntity.tick` up to its move's vertical contact: the fluids, the water splash, the move
|
||||
/// (pushed out at `escape_speed` when inside a block) and its fall.
|
||||
pub(in crate::game) fn move_item(
|
||||
&self,
|
||||
item: &mut ItemEntity,
|
||||
escape_speed: Option<f32>,
|
||||
entity_boxes: &[Aabb],
|
||||
splash: &mut impl FnMut(ItemSplash),
|
||||
) -> ItemStep {
|
||||
let heights = item.push_by_fluid(self.world, &self.motion.fluids);
|
||||
// Entity.baseTick splashes before moving, except on the first tick.
|
||||
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
|
||||
item.lava_fall(heights.1 > 0.0);
|
||||
let step = item.tick_move_before_block_effects(
|
||||
self.world,
|
||||
self.facts,
|
||||
self.collision,
|
||||
escape_speed.is_some(),
|
||||
escape_speed,
|
||||
heights,
|
||||
entity_boxes,
|
||||
Some(self.border),
|
||||
Some(&self.motion.blocks),
|
||||
);
|
||||
item.check_fall(&step, |fall, from, moved| {
|
||||
fall_reset(self.world, self.motion, self.facts, fall, from, moved)
|
||||
});
|
||||
step
|
||||
}
|
||||
/// The rest of the move after its vertical contact: block effects, bubble columns, settling
|
||||
/// and the water entry. Whether the item lives on.
|
||||
pub(in crate::game) fn settle_item(
|
||||
&self,
|
||||
item: &mut ItemEntity,
|
||||
no_physics: bool,
|
||||
step: ItemStep,
|
||||
splash: &mut impl FnMut(ItemSplash),
|
||||
) -> bool {
|
||||
item.finish_move_block_effects(
|
||||
self.world,
|
||||
self.facts,
|
||||
no_physics,
|
||||
Some(&self.motion.blocks),
|
||||
step,
|
||||
);
|
||||
item.push_by_bubble_column(self.world, self.registry, self.facts, self.collision);
|
||||
let alive = item.tick_settle(self.world, self.facts, no_physics, step);
|
||||
// ItemEntity.tick refreshes immersion and splashes on water entry.
|
||||
let (water, _) = item.push_by_fluid(self.world, &self.motion.fluids);
|
||||
item.water_entry(water > 0.0, false, splash);
|
||||
alive
|
||||
}
|
||||
}
|
||||
|
||||
/// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the box around an item that its
|
||||
/// move reads others' boxes in (a block of slack for the next velocity).
|
||||
pub(super) fn reach(item: &ItemEntity) -> Aabb {
|
||||
let v = item.velocity;
|
||||
item.bounds()
|
||||
.inflate(Vec3::new(v.x.abs() + 1.0, v.y.abs() + 1.0, v.z.abs() + 1.0))
|
||||
}
|
||||
|
||||
/// An item's move as its turn makes it: without physics (inside a block), alive, discarded, and
|
||||
/// its splashes.
|
||||
pub(super) type Moved = (bool, bool, bool, Vec<ItemSplash>);
|
||||
|
||||
/// One item's move, worked out on a copy.
|
||||
pub(super) struct Ahead {
|
||||
moved: ItemEntity,
|
||||
result: Moved,
|
||||
/// What a merge before its turn can change: its count, age and pickup delay.
|
||||
started: (u32, i32, i64),
|
||||
}
|
||||
|
||||
fn started(item: &ItemEntity) -> (u32, i32, i64) {
|
||||
(item.stack.count, item.age, item.pickup_delay)
|
||||
}
|
||||
|
||||
/// `item`'s whole move on a server with no native mod, from the tables alone.
|
||||
fn move_alone(
|
||||
tables: &ItemTables,
|
||||
obstacles: &ObstacleGrid,
|
||||
item: &mut ItemEntity,
|
||||
found: &mut Vec<u32>,
|
||||
near: &mut Vec<Aabb>,
|
||||
) -> Moved {
|
||||
obstacles.near_shared(reach(item), found, near);
|
||||
let no_physics = item.inside_block(tables.world, tables.collision, near, Some(tables.border));
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut splashes = Vec::new();
|
||||
let step = tables.move_item(item, escape_speed, near, &mut |s| splashes.push(s));
|
||||
if step.vertical_contact.is_some() && item.stack.count == 0 {
|
||||
return (no_physics, false, true, splashes);
|
||||
}
|
||||
let alive = tables.settle_item(item, no_physics, step, &mut |s| splashes.push(s));
|
||||
(no_physics, alive, false, splashes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's choice of the path, over the parallel tick's switch.
|
||||
static AHEAD: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
|
||||
/// The moves a test's tick put in place, and those a merge made it refuse.
|
||||
static TAKEN: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
/// Whether the moves are worked out ahead: while the parallel tick is on.
|
||||
fn ahead() -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(ahead) = AHEAD.with(std::cell::Cell::get) {
|
||||
return ahead;
|
||||
}
|
||||
horizon_client::parallel::lanes() > 1
|
||||
}
|
||||
|
||||
/// The moves worked out ahead, by the item's place in the loop.
|
||||
pub(super) struct Moves(Vec<Option<Ahead>>);
|
||||
|
||||
impl Moves {
|
||||
/// The moves of the items that will tick (`ticks`), on the helper threads; none while the
|
||||
/// parallel tick is off, for few items, or with a mod that could see an item's move.
|
||||
pub(super) fn work_out(
|
||||
game: &Game,
|
||||
items: &[ItemEntity],
|
||||
obstacles: &ObstacleGrid,
|
||||
round: &native_ticks::TickRound,
|
||||
ticks: impl Fn(&ItemEntity) -> bool,
|
||||
) -> Self {
|
||||
if !ahead()
|
||||
|| items.len() < MIN_ITEMS
|
||||
|| game.remote.is_some()
|
||||
|| round.listening()
|
||||
|| !horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
return Self(Vec::new());
|
||||
}
|
||||
let todo: Vec<usize> = (0..items.len()).filter(|&i| ticks(&items[i])).collect();
|
||||
let tables = game.item_tables();
|
||||
let done = horizon_client::parallel::par_map(&todo, MIN_ITEMS / 4, |part, out| {
|
||||
let (mut found, mut near) = (Vec::new(), Vec::new());
|
||||
for &i in part {
|
||||
let mut moved = items[i].clone();
|
||||
let result = move_alone(&tables, obstacles, &mut moved, &mut found, &mut near);
|
||||
let started = started(&items[i]);
|
||||
out.push((
|
||||
i,
|
||||
Ahead {
|
||||
moved,
|
||||
result,
|
||||
started,
|
||||
},
|
||||
));
|
||||
}
|
||||
});
|
||||
let mut moves: Vec<Option<Ahead>> = (0..items.len()).map(|_| None).collect();
|
||||
for (i, a) in done {
|
||||
moves[i] = Some(a);
|
||||
}
|
||||
Self(moves)
|
||||
}
|
||||
/// Item `i`'s move, put in place, when it was worked out and no merge changed the item since.
|
||||
/// Under `HORIZON_PARALLEL_TICK=verify` the move is made again at its turn, on a copy, and a
|
||||
/// difference is logged.
|
||||
pub(super) fn take(
|
||||
&mut self,
|
||||
i: usize,
|
||||
item: &mut ItemEntity,
|
||||
again: impl FnOnce(&mut ItemEntity) -> Moved,
|
||||
) -> Option<Moved> {
|
||||
let a = self.0.get_mut(i)?.take()?;
|
||||
let fits = a.started == started(item);
|
||||
#[cfg(test)]
|
||||
TAKEN.with(|t| {
|
||||
let (taken, refused) = t.get();
|
||||
t.set((taken + usize::from(fits), refused + usize::from(!fits)));
|
||||
});
|
||||
if !fits {
|
||||
return None;
|
||||
}
|
||||
if horizon_client::parallel::parallel_tick() == 2 {
|
||||
let mut serial = item.clone();
|
||||
let expected = again(&mut serial);
|
||||
check(item.id, &serial, &expected, &a.moved, &a.result);
|
||||
}
|
||||
*item = a.moved;
|
||||
Some(a.result)
|
||||
}
|
||||
}
|
||||
|
||||
/// `HORIZON_PARALLEL_TICK=verify`: an item's move worked out ahead against the one at its turn.
|
||||
fn check(id: u32, serial: &ItemEntity, expected: &Moved, ahead: &ItemEntity, got: &Moved) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static CHECKED: AtomicU64 = AtomicU64::new(0);
|
||||
static DIFFERED: AtomicU64 = AtomicU64::new(0);
|
||||
if format!("{serial:?} {expected:?}") != format!("{ahead:?} {got:?}") {
|
||||
DIFFERED.fetch_add(1, Ordering::Relaxed);
|
||||
log::error!("parallel tick: item {id}'s move worked out ahead differs from its turn's");
|
||||
}
|
||||
let n = CHECKED.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if n == 1 || n.is_multiple_of(10_000) {
|
||||
log::info!(
|
||||
"parallel tick: {n} item moves worked out ahead checked, {} differed",
|
||||
DIFFERED.load(Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::native_machines::tests::server_game;
|
||||
use super::{AHEAD, TAKEN};
|
||||
use horizon_client::{chunk::Chunk, items::ItemStack};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// One item tick with the moves worked out ahead leaves every item, and the level's random,
|
||||
/// as the tick that moves each item at its turn: items escaping a block (their own random),
|
||||
/// floating in water, falling, and merging into a later item before its turn (whose move
|
||||
/// is then made at its turn).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn moves_worked_out_ahead_match_those_made_at_each_turn() {
|
||||
let mut game = server_game();
|
||||
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
// `ChunkMap.prepareEntityTickingChunk`: the chunks within 2 are loaded too.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(8, 8));
|
||||
game.border.set_center([8.0, 8.0]);
|
||||
game.border.set_size(16.0);
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
let mut escaping = Vec::new();
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
|
||||
let stack = |count| ItemStack::new("minecraft:stone", count);
|
||||
match (x + z) % 5 {
|
||||
0 => {
|
||||
game.world.set(x, 65, z, stone).unwrap();
|
||||
game.spawn_item(stack(1), [fx, 65.3, fz], [0.0; 3], 10);
|
||||
escaping.push(game.dropped.last().unwrap().id);
|
||||
}
|
||||
1 => {
|
||||
game.world.set(x, 65, z, water).unwrap();
|
||||
game.spawn_item(stack(1), [fx, 65.5, fz], [0.0; 3], 10);
|
||||
}
|
||||
2 => {
|
||||
game.spawn_item(stack(1), [fx, 66.0, fz], [0.0; 3], 0);
|
||||
game.spawn_item(stack(5), [fx, 66.0, fz], [0.0; 3], 0);
|
||||
}
|
||||
_ => game.spawn_item(stack(1), [fx, 67.0, fz], [0.1, 0.2, -0.1], 10),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Real solid-entity boxes, not a stubbed obstacle query. One item is
|
||||
// caught inside the boat; another falls onto its top this tick.
|
||||
game.next_entity += 1;
|
||||
let boat = super::super::super::boats::Boat::new(
|
||||
game.next_entity,
|
||||
Arc::from("oak"),
|
||||
Vec3::new(8.5, 80.0, 8.5),
|
||||
0.0,
|
||||
);
|
||||
let boat_top = boat.bounds().max_position().y;
|
||||
game.boats.push(boat);
|
||||
game.spawn_item(
|
||||
ItemStack::new("minecraft:iron_ingot", 1),
|
||||
[8.5, 80.2, 8.5],
|
||||
[0.0; 3],
|
||||
10,
|
||||
);
|
||||
let caught = game.dropped.last().unwrap().id;
|
||||
escaping.push(caught);
|
||||
game.spawn_item(
|
||||
ItemStack::new("minecraft:gold_ingot", 1),
|
||||
[8.5, 81.0, 8.5],
|
||||
[0.0, -1.0, 0.0],
|
||||
10,
|
||||
);
|
||||
let landing = game.dropped.last().unwrap().id;
|
||||
// The border cuts the box even though the centre is still inside it.
|
||||
game.spawn_item(
|
||||
ItemStack::new("minecraft:diamond", 1),
|
||||
[15.99, 80.0, 8.5],
|
||||
[0.0; 3],
|
||||
10,
|
||||
);
|
||||
let border = game.dropped.last().unwrap().id;
|
||||
escaping.push(border);
|
||||
let boxes = game.item_entity_collisions();
|
||||
for id in [caught, border] {
|
||||
let item = game.dropped.iter().find(|i| i.id == id).unwrap();
|
||||
assert!(item.inside_block(
|
||||
&game.world,
|
||||
game.collision,
|
||||
&boxes,
|
||||
Some(game.border.geometry())
|
||||
));
|
||||
assert!(!item.inside_block(&game.world, game.collision, &[], None));
|
||||
}
|
||||
for item in game.dropped.iter_mut() {
|
||||
item.ticks = 39;
|
||||
}
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
let (items, random) = (game.dropped.clone(), game.world.random.clone());
|
||||
// Each merge-written field independently invalidates a prepared move.
|
||||
// Rejecting it must not overwrite the merge or advance the item's RNG.
|
||||
AHEAD.with(|a| a.set(Some(true)));
|
||||
let obstacles = super::ObstacleGrid::new(boxes);
|
||||
for changed in 0..3 {
|
||||
let mut moves = super::Moves::work_out(
|
||||
&game,
|
||||
&items,
|
||||
&obstacles,
|
||||
&super::native_ticks::TickRound::default(),
|
||||
|_| true,
|
||||
);
|
||||
let mut item = items[0].clone();
|
||||
match changed {
|
||||
0 => item.stack.count += 1,
|
||||
1 => item.age += 1,
|
||||
_ => item.pickup_delay += 1,
|
||||
}
|
||||
let before = format!("{item:?}");
|
||||
assert!(
|
||||
moves
|
||||
.take(0, &mut item, |_| panic!("invalidated move was verified"))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(format!("{item:?}"), before);
|
||||
}
|
||||
let mut runs = Vec::new();
|
||||
// First use the real environment switch, then serial/ahead/serial
|
||||
// overrides. This also checks switch-off behavior above the threshold.
|
||||
for ahead in [None, Some(false), Some(true), Some(false)] {
|
||||
game.dropped = items.clone();
|
||||
game.world.random = random.clone();
|
||||
AHEAD.with(|a| a.set(ahead));
|
||||
TAKEN.with(|t| t.set((0, 0)));
|
||||
game.tick_items();
|
||||
// Independently advance just the escaping items' starting randoms.
|
||||
// The helper copy must consume exactly one float and return that
|
||||
// state at the turn; ordinary moves must consume none of it.
|
||||
for item in game.dropped.iter() {
|
||||
let before = items.iter().find(|i| i.id == item.id).unwrap();
|
||||
let mut expected = before.random.clone();
|
||||
if escaping.contains(&item.id) {
|
||||
expected.next_float();
|
||||
}
|
||||
assert_eq!(
|
||||
item.random.clone().next_int(),
|
||||
expected.next_int(),
|
||||
"item {}",
|
||||
item.id
|
||||
);
|
||||
}
|
||||
let landed = game.dropped.iter().find(|i| i.id == landing).unwrap();
|
||||
assert_eq!(landed.position.y, boat_top);
|
||||
assert!(landed.on_ground);
|
||||
assert_eq!(format!("{:?}", game.world.random), format!("{random:?}"));
|
||||
let left: Vec<String> = game.dropped.iter().map(|i| format!("{i:?}")).collect();
|
||||
runs.push((
|
||||
left,
|
||||
format!("{:?}", game.world.random),
|
||||
TAKEN.with(|t| t.get()),
|
||||
));
|
||||
}
|
||||
AHEAD.with(|a| a.set(None));
|
||||
assert!(runs[0].0.len() < items.len(), "some items merged");
|
||||
assert_eq!(runs[0].0, runs[1].0);
|
||||
assert_eq!(runs[0].1, runs[1].1);
|
||||
assert_eq!(runs[0].0, runs[2].0);
|
||||
assert_eq!(runs[0].1, runs[2].1);
|
||||
assert_eq!(runs[0].0, runs[3].0);
|
||||
assert_eq!(runs[0].1, runs[3].1);
|
||||
assert_eq!(runs[1].2, (0, 0));
|
||||
assert_eq!(runs[3].2, (0, 0));
|
||||
if horizon_client::parallel::lanes() > 1 {
|
||||
assert_eq!(runs[0].2, runs[2].2);
|
||||
} else {
|
||||
assert_eq!(runs[0].2, (0, 0));
|
||||
}
|
||||
// The path under test ran: most moves were put in place, and a merge refused some.
|
||||
let (taken, refused) = runs[2].2;
|
||||
assert!(
|
||||
taken >= 200 && refused > 0,
|
||||
"{taken} put in place, {refused} refused"
|
||||
);
|
||||
eprintln!(
|
||||
"item work-ahead proof: {} inputs, {} escaping; {taken} taken, {refused} invalidated",
|
||||
items.len(),
|
||||
escaping.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
//! costs one `Option` check per tick.
|
||||
mod event_timings;
|
||||
mod level_event_targets;
|
||||
mod mob_states;
|
||||
|
||||
use super::{Game, shield::OFFHAND_SLOT};
|
||||
use event_timings::EventTimings;
|
||||
@@ -364,12 +363,12 @@ pub(super) fn baby_index(species: Species) -> Option<u8> {
|
||||
/// own: `mob_meta`'s arms hold these mobs' other entries.
|
||||
fn mob_variant_meta(
|
||||
m: &Mob,
|
||||
sync: &SyncData,
|
||||
hub: &Hub,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let (kind, e) = (m.kind.id.as_str(), &m.extra);
|
||||
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
// Axolotl.DATA_VARIANT (after AgeableMob's two).
|
||||
"minecraft:axolotl" => {
|
||||
@@ -502,7 +501,7 @@ fn mob_variant_meta(
|
||||
/// `defaults` gets the defaults of the entries that can go back to theirs.
|
||||
pub(super) fn mob_meta(
|
||||
m: &Mob,
|
||||
sync: &SyncData,
|
||||
hub: &Hub,
|
||||
player_entity: Option<i32>,
|
||||
game_time: i64,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
@@ -559,7 +558,7 @@ pub(super) fn mob_meta(
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
|
||||
// swirls of its effects (the ones a mob has are visible, not ambient).
|
||||
meta.extend(
|
||||
sync.effect_meta(
|
||||
hub.effect_meta(
|
||||
(m.extra.effects.iter())
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.map(|e| (e.0, m.extra.ambient_effects.contains(&e.0))),
|
||||
@@ -591,7 +590,7 @@ pub(super) fn mob_meta(
|
||||
}
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry
|
||||
// after AgeableMob's two). The total `boost()` stored in `counter2`.
|
||||
// The default 0 is not registered: vanilla never writes it back, and the
|
||||
@@ -599,7 +598,7 @@ pub(super) fn mob_meta(
|
||||
if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.counter2)));
|
||||
}
|
||||
mob_variant_meta(m, sync, meta, defaults);
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
mob_raider_meta(m, meta, defaults);
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
@@ -1949,15 +1948,14 @@ impl Game<'_> {
|
||||
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
|
||||
) {
|
||||
out.clear();
|
||||
let mut states = mob_states::Later::new(self.world.game_time);
|
||||
let mut speeds = super::lan_attributes::SpeedByType::default();
|
||||
// ChunkMap.tick sends the changes of an entity outside the
|
||||
// entity-ticking range only when it changed section or needs sync.
|
||||
let levels = self.tickets.levels();
|
||||
let idle_at = |p: horizon_core::geometry::Vec3| {
|
||||
!levels.in_entity_ticking_range((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
};
|
||||
for (index, m) in (self.mobs.iter().enumerate()).filter(|(_, m)| !m.removed && m.id > after)
|
||||
{
|
||||
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
|
||||
// The lead's holder: the player the mob sees, another mob or a
|
||||
// fence knot (game/lan_entities.rs).
|
||||
let player_entity = || {
|
||||
@@ -1988,7 +1986,6 @@ impl Game<'_> {
|
||||
let bare = idle
|
||||
&& (kept.as_deref_mut()).is_some_and(|k| k.bare(hub, m.id as i32, kind, &place))
|
||||
&& !m.needs_sync;
|
||||
let slot = out.as_slice().len();
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), kind);
|
||||
e.position = place.position;
|
||||
(e.yaw, e.pitch, e.head_yaw) = place.rotation;
|
||||
@@ -2002,11 +1999,21 @@ impl Game<'_> {
|
||||
if bare {
|
||||
continue;
|
||||
}
|
||||
let (sync, sources) = (hub.sync_data(), self.attr_sources());
|
||||
states.state((slot, index), m, player_entity(), sync, sources, e);
|
||||
mob_meta(
|
||||
m,
|
||||
hub,
|
||||
player_entity(),
|
||||
self.world.game_time,
|
||||
&mut e.meta,
|
||||
&mut e.defaults,
|
||||
);
|
||||
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
|
||||
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
|
||||
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut speeds, &mut e.attributes);
|
||||
}
|
||||
states.fill(&self.mobs, hub.sync_data(), self.attr_sources(), out);
|
||||
for d in self.dropped.iter().filter(|d| d.id > after) {
|
||||
let idle = idle_at(d.position);
|
||||
let place = super::lan_idle::Place {
|
||||
@@ -4109,7 +4116,7 @@ mod tests {
|
||||
let hub = meta_hub("RaiderMetaTest");
|
||||
let meta_of = |m: &Mob| {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
|
||||
@@ -4138,7 +4145,7 @@ mod tests {
|
||||
// The same health: the ghoul's own maximum is 40, the zombie's 20.
|
||||
m.health = 10.0;
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(&m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(&m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
for (template, id) in [
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
//! The part of each mob's state for its clients (`Game::lan_entities`) that reads only the mob and
|
||||
//! the synced tables: its entity data and attribute instances. While the parallel tick is on
|
||||
//! (`horizon_client::parallel`) it is built after the pass, on the tick's helper threads, and put in
|
||||
//! place; otherwise in place during the pass, as one thread always did.
|
||||
use super::super::lan_attributes::{AttrSources, SpeedByType};
|
||||
use super::{mob_freeze_meta, mob_meta};
|
||||
use horizon_client::lan::{
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
};
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_protocol::{entity_data::Value, packets::play::AttributeSnapshot};
|
||||
|
||||
/// From this many mobs the states go to the helper threads.
|
||||
const MIN_MOBS: usize = 1024;
|
||||
|
||||
/// One mob's entity data (`meta`, and the `defaults` its entries can go back to) and attribute
|
||||
/// instances; whether its position is sent precisely.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn mob_state(
|
||||
m: &Mob,
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
player_entity: Option<i32>,
|
||||
game_time: i64,
|
||||
speeds: &mut SpeedByType,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
attributes: &mut Vec<AttributeSnapshot>,
|
||||
) -> bool {
|
||||
mob_meta(m, sync, player_entity, game_time, meta, defaults);
|
||||
mob_freeze_meta(m, meta, defaults);
|
||||
sources.mob_attributes(m, speeds, attributes);
|
||||
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
|
||||
m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& horizon_client::mobs::happy_ghast::on_still_timeout(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's choice of the path, over the parallel tick's switch.
|
||||
static DEFER: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Whether the states wait for the helper threads: while the parallel tick is on.
|
||||
fn deferring() -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(defer) = DEFER.with(std::cell::Cell::get) {
|
||||
return defer;
|
||||
}
|
||||
horizon_client::parallel::lanes() > 1
|
||||
}
|
||||
|
||||
/// The mobs' states of one pass: built in place, or (while the parallel tick is on) the mobs whose
|
||||
/// states wait for the helper threads: (their slot in the states, their index in the mobs, the
|
||||
/// player entity their lead may name).
|
||||
pub(super) struct Later {
|
||||
waiting: Option<Vec<(usize, usize, Option<i32>)>>,
|
||||
game_time: i64,
|
||||
speeds: SpeedByType,
|
||||
}
|
||||
|
||||
impl Later {
|
||||
pub(super) fn new(game_time: i64) -> Self {
|
||||
Self {
|
||||
waiting: deferring().then(Vec::new),
|
||||
game_time,
|
||||
speeds: SpeedByType::default(),
|
||||
}
|
||||
}
|
||||
/// Mob `index`'s state `e`, in `slot` of the states: built now, or left for [`Self::fill`].
|
||||
pub(super) fn state(
|
||||
&mut self,
|
||||
(slot, index): (usize, usize),
|
||||
m: &Mob,
|
||||
player_entity: Option<i32>,
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
e: &mut EntityState,
|
||||
) {
|
||||
if let Some(waiting) = &mut self.waiting {
|
||||
waiting.push((slot, index, player_entity));
|
||||
return;
|
||||
}
|
||||
let (meta, defaults, attributes) = (&mut e.meta, &mut e.defaults, &mut e.attributes);
|
||||
e.precise = mob_state(
|
||||
m,
|
||||
sync,
|
||||
sources,
|
||||
player_entity,
|
||||
self.game_time,
|
||||
&mut self.speeds,
|
||||
meta,
|
||||
defaults,
|
||||
attributes,
|
||||
);
|
||||
}
|
||||
/// The waiting states, built on the helper threads and put in place.
|
||||
pub(super) fn fill(
|
||||
self,
|
||||
mobs: &[Mob],
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
out: &mut States,
|
||||
) {
|
||||
let Some(waiting) = self.waiting else {
|
||||
return;
|
||||
};
|
||||
let game_time = self.game_time;
|
||||
let built = horizon_client::parallel::par_map(&waiting, MIN_MOBS, |part, done| {
|
||||
let mut speeds = SpeedByType::default();
|
||||
for &(slot, index, player) in part {
|
||||
let (mut meta, mut defaults, mut attributes) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let precise = mob_state(
|
||||
&mobs[index],
|
||||
sync,
|
||||
sources,
|
||||
player,
|
||||
game_time,
|
||||
&mut speeds,
|
||||
&mut meta,
|
||||
&mut defaults,
|
||||
&mut attributes,
|
||||
);
|
||||
done.push((slot, meta, defaults, attributes, precise));
|
||||
}
|
||||
});
|
||||
let states = out.as_mut_slice();
|
||||
for (slot, meta, defaults, attributes, precise) in built {
|
||||
let e = &mut states[slot];
|
||||
(e.meta, e.defaults, e.attributes, e.precise) = (meta, defaults, attributes, precise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::lan::{NetConfig, hub::HubConfig};
|
||||
|
||||
/// The states put in place after the pass are those built in place during it, for mobs whose
|
||||
/// entity data and attributes vary: effects, armor, a horse's rolled jump, variants, a baby,
|
||||
/// a happy ghast, and what `/attribute` changed.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn deferred_states_match_those_built_in_place() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::lan::mob_states::tests::deferred_states_match_those_built_in_place",
|
||||
deferred_states_match_those_built_in_place_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn deferred_states_match_those_built_in_place_in_own_process() {
|
||||
let mut game = super::super::super::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for command in [
|
||||
"summon piglin 8.5 80 8.5 {NoAI:1b,PersistenceRequired:1b}",
|
||||
"effect give @e[type=piglin] speed 30 1",
|
||||
"item replace entity @e[type=piglin,limit=1] armor.head with iron_helmet",
|
||||
"summon horse 10.5 80 8.5 {NoAI:1b}",
|
||||
"summon sheep 12.5 80 8.5 {NoAI:1b,Color:3b,Age:-24000}",
|
||||
"summon axolotl 14.5 80 8.5 {NoAI:1b,Variant:2}",
|
||||
"summon happy_ghast 16.5 84 8.5 {NoAI:1b}",
|
||||
"summon pig 18.5 80 8.5 {NoAI:1b}",
|
||||
"summon frog 20.5 80 8.5 {NoAI:1b}",
|
||||
"attribute @e[type=pig,limit=1] minecraft:scale base set 1.5",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
let examples = game.mobs.clone();
|
||||
game.mobs = (0..MIN_MOBS * 2 + 37)
|
||||
.map(|i| {
|
||||
let mut mob = examples[i % examples.len()].clone();
|
||||
mob.id = 10_000 + i as u32;
|
||||
mob.removed = i % 11 == 0;
|
||||
mob
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
game.lan_host(
|
||||
super::super::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig::loopback(0, String::new(), 2),
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 20,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut lan = game.lan.take().unwrap();
|
||||
// The shared tables must retain pack-selected effect colors, not just
|
||||
// vanilla's registry color. This process owns the global pack palette.
|
||||
horizon_client::brewing::set_pack_colors(vec![("speed".into(), 0xAA12_3456)]);
|
||||
let players = Default::default();
|
||||
let mut runs = Vec::new();
|
||||
struct Reset;
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
DEFER.with(|d| d.set(None));
|
||||
}
|
||||
}
|
||||
let _reset = Reset;
|
||||
for after in [0, 10_012] {
|
||||
runs.clear();
|
||||
for defer in [false, true, false] {
|
||||
DEFER.with(|d| d.set(Some(defer)));
|
||||
let mut states = States::default();
|
||||
game.lan_entities(&players, &mut lan.hub, &mut states, false, after, None);
|
||||
runs.push(states.as_slice().to_vec());
|
||||
}
|
||||
let expected_ids: Vec<_> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.id > after)
|
||||
.map(|m| m.id as i32)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
runs[0].iter().map(|e| e.id).collect::<Vec<_>>(),
|
||||
expected_ids
|
||||
);
|
||||
assert!(runs[0].iter().filter(|e| !e.meta.is_empty()).count() >= MIN_MOBS);
|
||||
assert!(runs[0].iter().any(|e| !e.attributes.is_empty()));
|
||||
let colored: Vec<_> = runs[0]
|
||||
.iter()
|
||||
.filter(|e| e.kind == "minecraft:piglin")
|
||||
.collect();
|
||||
assert!(!colored.is_empty());
|
||||
for e in colored {
|
||||
let particles = e.meta.iter().find(|e| e.0 == 10).unwrap();
|
||||
let Value::Particles(particles) = &particles.2 else {
|
||||
panic!("effect particle metadata");
|
||||
};
|
||||
assert_eq!(particles.len(), 1);
|
||||
assert_eq!(
|
||||
particles[0].data,
|
||||
Some(horizon_protocol::entity_data::ParticleData::Color(
|
||||
0xFF12_3456_u32 as i32
|
||||
))
|
||||
);
|
||||
}
|
||||
assert_eq!(runs[0], runs[1], "deferred, after {after}");
|
||||
assert_eq!(runs[0], runs[2], "switch off, after {after}");
|
||||
|
||||
// With deferral off the caller can read the metadata immediately,
|
||||
// before fill, and fill must not replace the vectors it already owns.
|
||||
let m = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| !m.removed && m.id > after)
|
||||
.unwrap();
|
||||
let mut states = States::default();
|
||||
let e = states.add(m.id as i32, 0, &m.kind.id);
|
||||
let mut later = Later::new(game.world.game_time);
|
||||
later.state((0, 0), m, None, lan.hub.sync_data(), game.attr_sources(), e);
|
||||
assert_eq!(e.meta, runs[0][0].meta);
|
||||
assert_eq!(e.defaults, runs[0][0].defaults);
|
||||
assert_eq!(e.attributes, runs[0][0].attributes);
|
||||
let pointers = (e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr());
|
||||
later.fill(
|
||||
&game.mobs,
|
||||
lan.hub.sync_data(),
|
||||
game.attr_sources(),
|
||||
&mut states,
|
||||
);
|
||||
let e = &states.as_slice()[0];
|
||||
assert_eq!(
|
||||
pointers,
|
||||
(e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ fn a_sulfur_cube_sends_its_fuse_at_19_and_from_bucket_at_20() {
|
||||
let hub = meta_hub("SulfurCubeMetaTest");
|
||||
let meta_of = |m: &Mob| {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
|
||||
|
||||
@@ -103,32 +103,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a mob's synced attribute instances are read from (`mob_attributes`).
|
||||
pub(super) fn attr_sources(&self) -> AttrSources<'_> {
|
||||
AttrSources {
|
||||
catalog: &self.motion.attributes,
|
||||
items: self.items(),
|
||||
commands: &self.cmds.attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the synced attribute instances are read from: the attribute catalog, the item table and
|
||||
/// what `/attribute` changed. Plain tables, so the tick's helper threads can read them
|
||||
/// (`Game::lan_entities`).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct AttrSources<'a> {
|
||||
catalog: &'a horizon_world::attributes::entity::Catalog,
|
||||
items: &'a horizon_client::items::Items,
|
||||
commands:
|
||||
&'a std::collections::HashMap<Target, horizon_world::attributes::entity::EntityAttributes>,
|
||||
}
|
||||
|
||||
impl AttrSources<'_> {
|
||||
/// The registry id of a client-syncable attribute the entity type has
|
||||
/// (`AttributeMap.getInstance` is null for the others), by name.
|
||||
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
|
||||
let catalog = self.catalog;
|
||||
let catalog = &self.motion.attributes;
|
||||
let id = catalog.id(name)?;
|
||||
(catalog.attribute(id).syncable && catalog.default_base(entity, id).is_some())
|
||||
.then(|| id.registry_id())
|
||||
@@ -136,7 +114,7 @@ impl AttrSources<'_> {
|
||||
|
||||
/// The type's base value of an attribute, as the instance starts.
|
||||
fn attribute_default(&self, entity: &str, name: &str) -> f64 {
|
||||
let catalog = self.catalog;
|
||||
let catalog = &self.motion.attributes;
|
||||
catalog
|
||||
.id(name)
|
||||
.and_then(|id| catalog.default_base(entity, id))
|
||||
@@ -154,7 +132,7 @@ impl AttrSources<'_> {
|
||||
out: &mut Vec<AttributeSnapshot>,
|
||||
) {
|
||||
for (slot, item) in slots {
|
||||
let Some(info) = item.and_then(|i| self.items.get(i)) else {
|
||||
let Some(info) = item.and_then(|i| self.items().get(i)) else {
|
||||
continue;
|
||||
};
|
||||
for (attribute, id, amount, op, group) in &info.modifiers {
|
||||
@@ -328,16 +306,6 @@ impl AttrSources<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The instances `/attribute` created in an entity's generic map
|
||||
/// replace the ones derived from its state.
|
||||
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
|
||||
if let Some(map) = self.commands.get(&t) {
|
||||
generic_attributes_into(map, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// An armor stand's synced instances: movement speed (its first
|
||||
/// `aiStep`), its equipment's modifiers and what `/attribute` changed.
|
||||
pub(super) fn stand_attributes(
|
||||
@@ -347,19 +315,18 @@ impl Game<'_> {
|
||||
) -> Vec<AttributeSnapshot> {
|
||||
const KIND: &str = "minecraft:armor_stand";
|
||||
let mut out = Vec::new();
|
||||
let sources = self.attr_sources();
|
||||
if let Some(a) = sources.synced_attribute(KIND, "minecraft:movement_speed") {
|
||||
if let Some(a) = self.synced_attribute(KIND, "minecraft:movement_speed") {
|
||||
instance(
|
||||
&mut out,
|
||||
a,
|
||||
sources.attribute_default(KIND, "minecraft:movement_speed"),
|
||||
self.attribute_default(KIND, "minecraft:movement_speed"),
|
||||
);
|
||||
}
|
||||
let slots = horizon_client::entities::ArmorStand::SLOTS
|
||||
.into_iter()
|
||||
.zip(equipment.iter().map(|s| s.as_ref().map(|s| &s.item)));
|
||||
sources.equipment_attributes(KIND, |_| None, slots, &mut out);
|
||||
sources.generic_attributes_of(Target::Stand(id), &mut out);
|
||||
self.equipment_attributes(KIND, |_| None, slots, &mut out);
|
||||
self.generic_attributes_of(Target::Stand(id), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -405,6 +372,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The instances `/attribute` created in an entity's generic map
|
||||
/// replace the ones derived from its state.
|
||||
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
|
||||
if let Some(map) = self.cmds.attributes.get(&t) {
|
||||
generic_attributes_into(map, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -146,6 +146,12 @@ impl WorldOptions {
|
||||
self.initial.difficulty = self.difficulty;
|
||||
self.initial.locked = self.locked;
|
||||
}
|
||||
/// `WorldOptionsScreen.updatePortControlsState`: the port box takes an
|
||||
/// edit only with LAN on; it stays focusable either way, so its value can
|
||||
/// be read, selected and copied while LAN is off.
|
||||
fn update_port_controls(&mut self) {
|
||||
self.port.editable = self.lan;
|
||||
}
|
||||
fn parsed_port(&self) -> Option<u16> {
|
||||
if self.port.value.is_empty() {
|
||||
return Some(self.hint);
|
||||
@@ -942,7 +948,7 @@ impl Game<'_> {
|
||||
if !o.remote {
|
||||
o.port.x = x + 158.0;
|
||||
o.port.y = top + PORT_TOP;
|
||||
o.port.focused = o.lan && o.port.focused;
|
||||
o.update_port_controls();
|
||||
o.port.text_color = if !o.lan {
|
||||
rgba(0x70, 0x70, 0x70, 255)
|
||||
} else if o.parsed_port().is_some() {
|
||||
@@ -1166,7 +1172,6 @@ impl Game<'_> {
|
||||
}
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
o.port.focused = !o.remote
|
||||
&& o.lan
|
||||
&& inside
|
||||
&& ui::hovered(
|
||||
self.cursor,
|
||||
@@ -1401,7 +1406,7 @@ impl Game<'_> {
|
||||
let reverse = shift;
|
||||
let next = match o.focused_button {
|
||||
Some(cur) => {
|
||||
if !o.remote && o.lan {
|
||||
if !o.remote {
|
||||
if !reverse && cur == Button::Lan {
|
||||
o.port.focused = true;
|
||||
o.focused_button = None;
|
||||
@@ -1922,4 +1927,30 @@ mod tests {
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
|
||||
}
|
||||
|
||||
/// `WorldOptionsScreen.updatePortControlsState`: with LAN off the port box
|
||||
/// is read-only but keeps the focus, so its value can be selected and
|
||||
/// copied; the caret moves, Backspace is consumed without a change, and
|
||||
/// with LAN on it edits again.
|
||||
#[test]
|
||||
fn the_port_box_is_read_only_but_focusable_with_lan_off() {
|
||||
use winit::keyboard::KeyCode;
|
||||
let mut o = options(false, false);
|
||||
assert!(!o.lan);
|
||||
o.port.focused = true;
|
||||
o.update_port_controls();
|
||||
assert!(!o.port.editable);
|
||||
assert!(o.port.focused);
|
||||
o.port.set_value("25565");
|
||||
assert!(o.port.key(KeyCode::Backspace, false, false));
|
||||
assert_eq!(o.port.value, "25565");
|
||||
assert!(o.port.key(KeyCode::ArrowLeft, false, false));
|
||||
assert_eq!(o.port.cursor, 4);
|
||||
// LAN on: the box takes the edit.
|
||||
o.lan = true;
|
||||
o.update_port_controls();
|
||||
assert!(o.port.editable);
|
||||
assert!(o.port.key(KeyCode::Backspace, false, false));
|
||||
assert_eq!(o.port.value, "2555");
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,18 @@ fn game() -> Game<'static> {
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
// Entities tick once the 2 columns around their chunk are loaded
|
||||
// (`ChunkMap.prepareEntityTickingChunk`), so the chunk of the mobs has all of them.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"fill 0 99 0 15 99 15 stone",
|
||||
@@ -153,8 +159,10 @@ fn the_saved_hurt_by_mob_follows_the_live_record_only() {
|
||||
let a = game.mobs[a_index].id;
|
||||
// No record, no raw copy: nothing.
|
||||
assert_eq!(fields(&mut game, victim), (None, None), "no record");
|
||||
// A raw copy from the summon, no live record: both keys go.
|
||||
// A raw copy from a stale compound, no live record and no read-back
|
||||
// reference: both keys go.
|
||||
let echo = index(&mut game, ECHO);
|
||||
game.mobs[echo].ai.last_hurt_by_ref = None;
|
||||
assert_eq!(fields(&mut game, echo), (None, None), "stale raw copy");
|
||||
// A live record of a resident mob: both keys, at 100 ticks still.
|
||||
let age = game.mobs[victim].age as i32;
|
||||
@@ -189,3 +197,121 @@ fn the_saved_hurt_by_mob_follows_the_live_record_only() {
|
||||
assert!(!game.mobs[ai].alive());
|
||||
assert_eq!(fields(&mut game, victim), (None, None), "dead attacker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_loaded_hurt_by_mob_is_read_back_and_ends_after_100_ticks() {
|
||||
use horizon_client::mobs::Who;
|
||||
let mut game = game();
|
||||
let echo = index(&mut game, ECHO);
|
||||
let a_index = index(&mut game, A);
|
||||
let a = game.mobs[a_index].id;
|
||||
// Read back at once (vanilla: `last_hurt_by_mob` A, ticks 0 then 1).
|
||||
assert_eq!(
|
||||
fields(&mut game, echo),
|
||||
(Some(Tag::IntArray(vec![24724, 0, 0, 2])), Some(Tag::Int(0))),
|
||||
"read back"
|
||||
);
|
||||
step(&mut game, 1);
|
||||
let echo = index(&mut game, ECHO);
|
||||
assert_eq!(game.mobs[echo].ai.last_hurt_by, Some(Who::Mob(a)));
|
||||
assert_eq!(fields(&mut game, echo).1, Some(Tag::Int(1)));
|
||||
// `tickCount - timestamp > 100` ends it: still there after 100 ticks.
|
||||
step(&mut game, 99);
|
||||
let echo = index(&mut game, ECHO);
|
||||
assert_eq!(game.mobs[echo].ai.last_hurt_by, Some(Who::Mob(a)));
|
||||
assert_eq!(fields(&mut game, echo).1, Some(Tag::Int(100)));
|
||||
step(&mut game, 1);
|
||||
let echo = index(&mut game, ECHO);
|
||||
assert_eq!(game.mobs[echo].ai.last_hurt_by, None, "101 ticks");
|
||||
assert_eq!(fields(&mut game, echo), (None, None), "101 ticks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_loaded_hurt_by_mob_of_a_dead_attacker_ends_at_the_next_tick() {
|
||||
let mut game = game();
|
||||
step(&mut game, 1);
|
||||
let echo = index(&mut game, ECHO);
|
||||
assert!(game.mobs[echo].ai.last_hurt_by.is_some(), "resolved");
|
||||
let a = index(&mut game, A);
|
||||
game.mobs[a].health = 0.0;
|
||||
step(&mut game, 1);
|
||||
let echo = index(&mut game, ECHO);
|
||||
assert_eq!(game.mobs[echo].ai.last_hurt_by, None, "dead attacker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_saved_hurt_timestamp_uses_java_integer_wrapping() {
|
||||
let mut game = game();
|
||||
let echo = index(&mut game, ECHO);
|
||||
game.mobs[echo].ai.last_hurt_by_time = (game.mobs[echo].age as i32).wrapping_sub(i32::MIN);
|
||||
step(&mut game, 1);
|
||||
let echo = index(&mut game, ECHO);
|
||||
assert!(game.mobs[echo].ai.last_hurt_by.is_some());
|
||||
assert_eq!(fields(&mut game, echo).1, Some(Tag::Int(i32::MIN + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_loaded_hurt_by_mob_naming_no_entity_stays_until_it_arrives() {
|
||||
let mut game = game();
|
||||
assert!(game.run_command(
|
||||
"summon pig 8.5 100 12.5 {NoAI:1b,PersistenceRequired:1b,UUID:[I;24724,0,0,5],last_hurt_by_mob:[I;24724,0,0,99],ticks_since_last_hurt_by_mob:7}"
|
||||
));
|
||||
let ghost = index(&mut game, "00006094-0000-0000-0000-000000000005");
|
||||
let named = Some(Tag::IntArray(vec![24724, 0, 0, 99]));
|
||||
assert_eq!(fields(&mut game, ghost), (named.clone(), Some(Tag::Int(7))));
|
||||
// Nothing resolves it: no baseTick end, however long, and it keeps aging.
|
||||
step(&mut game, 200);
|
||||
let ghost = index(&mut game, "00006094-0000-0000-0000-000000000005");
|
||||
assert_eq!(game.mobs[ghost].ai.last_hurt_by, None, "not the AI's yet");
|
||||
assert_eq!(fields(&mut game, ghost), (named, Some(Tag::Int(207))));
|
||||
// The entity it names arrives: it resolves, is past 100 ticks, and ends.
|
||||
assert!(game.run_command(
|
||||
"summon pig 12.5 100 12.5 {NoAI:1b,PersistenceRequired:1b,UUID:[I;24724,0,0,99]}"
|
||||
));
|
||||
step(&mut game, 1);
|
||||
let ghost = index(&mut game, "00006094-0000-0000-0000-000000000005");
|
||||
assert_eq!(
|
||||
fields(&mut game, ghost),
|
||||
(None, None),
|
||||
"resolved, then ended"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_loaded_hurt_by_mob_naming_the_mob_itself_resolves_and_ends_after_100_ticks() {
|
||||
use horizon_client::mobs::Who;
|
||||
// `EntityReference.getEntity` looks the UUID up in the level and does not
|
||||
// exclude the holder, so `baseTick` ends the record like any other.
|
||||
const SELF: &str = "00006094-0000-0000-0000-000000000006";
|
||||
let mut game = game();
|
||||
assert!(game.run_command(
|
||||
"summon pig 8.5 100 12.5 {NoAI:1b,PersistenceRequired:1b,UUID:[I;24724,0,0,6],last_hurt_by_mob:[I;24724,0,0,6],ticks_since_last_hurt_by_mob:3}"
|
||||
));
|
||||
let named = Some(Tag::IntArray(vec![24724, 0, 0, 6]));
|
||||
let me = index(&mut game, SELF);
|
||||
let id = game.mobs[me].id;
|
||||
assert_eq!(fields(&mut game, me), (named.clone(), Some(Tag::Int(3))));
|
||||
step(&mut game, 1);
|
||||
let me = index(&mut game, SELF);
|
||||
assert_eq!(
|
||||
game.mobs[me].ai.last_hurt_by,
|
||||
Some(Who::Mob(id)),
|
||||
"resolved"
|
||||
);
|
||||
assert_eq!(game.mobs[me].ai.last_hurt_by_ref, None, "no bare reference");
|
||||
assert_eq!(fields(&mut game, me), (named.clone(), Some(Tag::Int(4))));
|
||||
// Aged 100 ticks: still there; one more and `baseTick` ends it.
|
||||
step(&mut game, 96);
|
||||
let me = index(&mut game, SELF);
|
||||
assert_eq!(game.mobs[me].ai.last_hurt_by, Some(Who::Mob(id)));
|
||||
assert_eq!(fields(&mut game, me), (named, Some(Tag::Int(100))));
|
||||
step(&mut game, 1);
|
||||
let me = index(&mut game, SELF);
|
||||
assert_eq!(game.mobs[me].ai.last_hurt_by, None, "101 ticks");
|
||||
assert_eq!(fields(&mut game, me), (None, None), "101 ticks");
|
||||
}
|
||||
@@ -316,6 +316,7 @@ impl Loading {
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.vsync,
|
||||
&self.settings.horizon,
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
self.renderer
|
||||
@@ -466,17 +467,26 @@ impl ApplicationHandler for App {
|
||||
|
||||
/// Every launch reads the prepared game files (`horizon-prepare`'s output, whose
|
||||
/// manifest is `horizon-resources.json`): without them, say where they were
|
||||
/// looked for and how to get them, before a window or a loader starts.
|
||||
pub(super) fn missing_resources(root: &std::path::Path) -> Option<String> {
|
||||
/// looked for and how to get them, before a window or a loader starts. `get` is
|
||||
/// the page a hook sends the player to (`horizon_hooks::engine::get_the_game`).
|
||||
pub(super) fn missing_resources(
|
||||
root: &std::path::Path,
|
||||
get: Option<horizon_hooks::GetTheGame>,
|
||||
) -> Option<String> {
|
||||
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
if !root.join("horizon-resources.json").is_file() {
|
||||
let launcher = get.map_or(String::new(), |get| {
|
||||
format!(
|
||||
"Horizon is started from its launcher: download it at {} (Download). It needs \
|
||||
your own Minecraft: Java Edition; the launcher signs you in and prepares the \
|
||||
game files.\nDevelopers: ",
|
||||
get.address
|
||||
)
|
||||
});
|
||||
return Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
{launcher}horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
));
|
||||
}
|
||||
damaged_prepared(root).map(|problem| {
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) fn build_renderer(
|
||||
prepared: Option<horizon_client::gfx::PreparedTextures>,
|
||||
api: horizon_client::graphics::GraphicsApi,
|
||||
translucent_batching: bool,
|
||||
vsync: bool,
|
||||
// HDR and Smooth Gradients pick the frame, with what the window's display reported to the
|
||||
// renderer before (`display`, `Renderer::new`).
|
||||
settings: &horizon_client::graphics::Graphics,
|
||||
@@ -79,6 +80,10 @@ pub(super) fn build_renderer(
|
||||
shader_pack.frame(),
|
||||
display,
|
||||
))?;
|
||||
// The loading renderer may have been built before the saved graphics settings were
|
||||
// adopted. Configure the world swapchain before its first gameplay frame instead of
|
||||
// waiting for `Game::apply_settings` after the handoff.
|
||||
renderer.set_present_mode(vsync, settings.present_mode);
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
|
||||
@@ -8,6 +8,16 @@ use horizon_client::{
|
||||
mobs::{Mob, Species, render},
|
||||
};
|
||||
|
||||
// `FishingRodCast`: the main hand takes precedence when both hold rods.
|
||||
fn fishing_cast_item(
|
||||
player_id: u32,
|
||||
hook_owners: impl Iterator<Item = u32>,
|
||||
main_hand_rod: bool,
|
||||
off_hand: bool,
|
||||
) -> bool {
|
||||
hook_owners.into_iter().any(|owner| owner == player_id) && main_hand_rod != off_hand
|
||||
}
|
||||
|
||||
/// `CarriedBlockLayer.submit`: block models use coordinates in [0, 1].
|
||||
fn carried_block_matrix(base: glam::Mat4) -> glam::Mat4 {
|
||||
base * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.6875, -0.75))
|
||||
@@ -461,13 +471,17 @@ impl Game<'_> {
|
||||
mob.body_yaw,
|
||||
&mob.kind.id,
|
||||
);
|
||||
// The local player's rod while its hook is out.
|
||||
owner.fishing_cast_with_this_item = self.cast_rod().is_some()
|
||||
&& self.main_hand_rod() != off_hand
|
||||
&& self
|
||||
.player_model
|
||||
.as_ref()
|
||||
.is_some_and(|p| std::ptr::eq(p, mob));
|
||||
owner.fishing_cast_with_this_item = if local_player {
|
||||
self.cast_rod().is_some() && self.main_hand_rod() != off_hand
|
||||
} else {
|
||||
let active = self.remote_hooks.iter().filter(|h| h.owner_fishing);
|
||||
fishing_cast_item(
|
||||
mob.id,
|
||||
active.map(|hook| hook.owner),
|
||||
mob.extra.main_hand.as_deref() == Some("minecraft:fishing_rod"),
|
||||
off_hand,
|
||||
)
|
||||
};
|
||||
owner.main_arm_left = mob.main_arm_left();
|
||||
if off_hand == mob.extra.using_off_hand && mob.extra.using_item > 0 {
|
||||
owner.using_this_item = true;
|
||||
@@ -703,6 +717,17 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_fishing_rod_cast_follows_owner_and_holding_hand() {
|
||||
let owners = [7, 12];
|
||||
assert!(fishing_cast_item(7, owners.into_iter(), true, false));
|
||||
assert!(!fishing_cast_item(7, owners.into_iter(), true, true));
|
||||
assert!(fishing_cast_item(12, owners.into_iter(), false, true));
|
||||
assert!(!fishing_cast_item(12, owners.into_iter(), false, false));
|
||||
assert!(!fishing_cast_item(8, owners.into_iter(), true, false));
|
||||
assert!(!fishing_cast_item(7, [].into_iter(), true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worn_head_follows_the_living_vehicle() {
|
||||
let (Some(mut wearer), Some(mut horse)) =
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! A game started without its prepared files (the game build run by hand, not
|
||||
//! through the launcher): a window that says where to get the launcher
|
||||
//! through a launcher): a window that says where to get them
|
||||
//! (`horizon_client::no_resources`) and quits when it is closed. No loader, no
|
||||
//! crash report: nothing went wrong in the game.
|
||||
use super::*;
|
||||
use horizon_client::no_resources::{Action, DOWNLOAD_URL, NoResources};
|
||||
use horizon_client::no_resources::{Action, NoResources};
|
||||
use winit::event::MouseButton;
|
||||
|
||||
struct Missing {
|
||||
@@ -19,6 +19,11 @@ struct Missing {
|
||||
|
||||
/// Shows the screen until the player closes it.
|
||||
pub(super) fn show(looked_in: &std::path::Path, settings: &Settings) -> Result<()> {
|
||||
// The player's language, from the strings compiled in: there are no prepared ones.
|
||||
horizon_client::lang::set(horizon_client::lang::Language::load(
|
||||
&[],
|
||||
&settings.language,
|
||||
));
|
||||
let mut app = Missing {
|
||||
window: None,
|
||||
renderer: None,
|
||||
@@ -26,7 +31,7 @@ pub(super) fn show(looked_in: &std::path::Path, settings: &Settings) -> Result<(
|
||||
atlas: HudAtlas::build(looked_in)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::colour([0; 4])),
|
||||
},
|
||||
screen: NoResources::new(looked_in),
|
||||
screen: NoResources::new(looked_in, horizon_hooks::engine::get_the_game()),
|
||||
pointer: (0.0, 0.0),
|
||||
api: settings.horizon.api,
|
||||
error: None,
|
||||
@@ -81,7 +86,7 @@ impl Missing {
|
||||
fn click(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let mut batch = HudBatch::new(self.size());
|
||||
match self.with_ui(&mut batch, |ui| self.screen.click(ui)) {
|
||||
Some(Action::Download) => packs::open_uri(DOWNLOAD_URL),
|
||||
Some(Action::Open(address)) => packs::open_uri(address),
|
||||
Some(Action::Quit) => event_loop.exit(),
|
||||
None => {}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,7 @@ impl Game<'_> {
|
||||
Some(textures),
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.vsync,
|
||||
&self.settings.horizon,
|
||||
super::shader_packs::picked_pack_shape(&self.settings),
|
||||
self.renderer
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#[cfg(test)]
|
||||
mod anger_tests;
|
||||
mod display;
|
||||
mod hurt_by_refs;
|
||||
|
||||
use super::{FallingBlock, Game};
|
||||
use horizon_client::{
|
||||
@@ -1614,42 +1615,6 @@ impl Game<'_> {
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
tag
|
||||
}
|
||||
/// `LivingEntity.addAdditionalSaveData`'s `last_hurt_by_mob` and
|
||||
/// `ticks_since_last_hurt_by_mob`, a PARTIAL projection at save time: both
|
||||
/// from `ai.last_hurt_by` while the attacker is a living mob in the level
|
||||
/// with a registered UUID and at most 100 ticks have passed (`baseTick`
|
||||
/// drops the record after that, or once the attacker is dead). Otherwise
|
||||
/// both keys go, a copy read from loaded data included. A player attacker
|
||||
/// is not written. This is not `baseTick`'s cleanup itself, the AI's
|
||||
/// record, `#no_anger`, or reading the keys back.
|
||||
pub(super) fn put_last_hurt_by_mob(&self, m: &horizon_client::mobs::Mob, tag: &mut Tag) {
|
||||
let Tag::Compound(c) = tag else { return };
|
||||
let since = i64::from(m.age) - i64::from(m.ai.last_hurt_by_time);
|
||||
let reference = match m.ai.last_hurt_by {
|
||||
Some(horizon_client::mobs::Who::Mob(attacker))
|
||||
if (0..=100).contains(&since)
|
||||
&& self.mobs.iter().any(|a| a.id == attacker && a.alive()) =>
|
||||
{
|
||||
self.persist.uuid_of(attacker)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match reference {
|
||||
Some(uuid) => {
|
||||
c.insert("last_hurt_by_mob".into(), Tag::IntArray(uuid.to_vec()));
|
||||
c.insert(
|
||||
"ticks_since_last_hurt_by_mob".into(),
|
||||
Tag::Int(since as i32),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
c.remove(&horizon_world::nbt::JavaString::from("last_hurt_by_mob"));
|
||||
c.remove(&horizon_world::nbt::JavaString::from(
|
||||
"ticks_since_last_hurt_by_mob",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Forget an entity that left the world (its UUID and loaded compound).
|
||||
pub(super) fn forget_entity(&mut self, id: u32) {
|
||||
if let Some(uuid) = self.persist.uuids.remove(&id) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! `LivingEntity.lastHurtByMob` is an `EntityReference`: a UUID that stands for
|
||||
//! the entity once it is in the level. What a mob saves of it, how a value read
|
||||
//! back resolves, and the same for the player in `lastHurtByPlayer`.
|
||||
use super::Game;
|
||||
use horizon_client::mobs::{Mob, Who};
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.addAdditionalSaveData`'s `last_hurt_by_mob` and
|
||||
/// `ticks_since_last_hurt_by_mob`, a PARTIAL projection at save time: both
|
||||
/// from `ai.last_hurt_by` while the attacker is a living mob in the level
|
||||
/// with a registered UUID and at most 100 ticks have passed (`baseTick`
|
||||
/// drops the record after that, or once the attacker is dead). Otherwise
|
||||
/// both keys go, a copy read from loaded data included. A player attacker
|
||||
/// is not written. A reference read back that names nothing in the level
|
||||
/// yet (`ai.last_hurt_by_ref`) is written as it is. This is not
|
||||
/// `baseTick`'s cleanup itself, the AI's record, or `#no_anger`.
|
||||
pub(in crate::game) fn put_last_hurt_by_mob(&self, m: &Mob, tag: &mut Tag) {
|
||||
let Tag::Compound(c) = tag else { return };
|
||||
// A reference read back and not resolved yet (its attacker is not in
|
||||
// the level) is kept as it is, for ever: `baseTick` cannot resolve it,
|
||||
// so it does not end it.
|
||||
if let Some(uuid) = m.ai.last_hurt_by_ref {
|
||||
c.insert("last_hurt_by_mob".into(), Tag::IntArray(uuid.to_vec()));
|
||||
c.insert(
|
||||
"ticks_since_last_hurt_by_mob".into(),
|
||||
Tag::Int((m.age as i32).wrapping_sub(m.ai.last_hurt_by_time)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let since = (m.age as i32).wrapping_sub(m.ai.last_hurt_by_time);
|
||||
let reference = match m.ai.last_hurt_by {
|
||||
Some(Who::Mob(attacker))
|
||||
if since <= 100 && self.mobs.iter().any(|a| a.id == attacker && a.alive()) =>
|
||||
{
|
||||
self.persist.uuid_of(attacker)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match reference {
|
||||
Some(uuid) => {
|
||||
c.insert("last_hurt_by_mob".into(), Tag::IntArray(uuid.to_vec()));
|
||||
c.insert("ticks_since_last_hurt_by_mob".into(), Tag::Int(since));
|
||||
}
|
||||
None => {
|
||||
c.remove(&JavaString::from("last_hurt_by_mob"));
|
||||
c.remove(&JavaString::from("ticks_since_last_hurt_by_mob"));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EntityReference.getEntity` for `last_hurt_by_mob` read back from a save:
|
||||
/// a mob whose reference names an entity now in the level (a mob, or the
|
||||
/// player) takes it as `last_hurt_by`, and `baseTick` ends it as it does
|
||||
/// any other. One naming nothing here stays a bare UUID, still saved. A
|
||||
/// reference to the mob itself resolves like any other (`getEntity` looks
|
||||
/// up the UUID and does not exclude the holder).
|
||||
pub(in crate::game) fn resolve_last_hurt_by_refs(&mut self) {
|
||||
let resolved: Vec<(usize, Who)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, m)| Some((i, self.who_by_uuid(m.ai.last_hurt_by_ref?)?)))
|
||||
.collect();
|
||||
for (i, who) in resolved {
|
||||
let ai = &mut self.mobs[i].ai;
|
||||
ai.last_hurt_by = Some(who);
|
||||
ai.last_hurt_by_ref = None;
|
||||
}
|
||||
}
|
||||
/// 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.
|
||||
pub(in crate::game) 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))
|
||||
}
|
||||
}
|
||||
@@ -266,7 +266,6 @@ fn server_ping_client_pong_keeps_the_existing_prediction_route() {
|
||||
.hub
|
||||
.guest_mut(1)
|
||||
.unwrap()
|
||||
.anticheat
|
||||
.record
|
||||
.as_deref_mut()
|
||||
.expect("existing client Pong updates prediction");
|
||||
|
||||
@@ -66,8 +66,7 @@ impl Game<'_> {
|
||||
motion.item_use_speed = item_use.map(|(speed, _)| speed);
|
||||
motion.item_use_slows_sprint = item_use.is_some_and(|(_, slows)| slows);
|
||||
}
|
||||
let (Some(record), Some(server)) = (g.anticheat.record.as_deref_mut(), g.motion.as_deref())
|
||||
else {
|
||||
let (Some(record), Some(server)) = (g.record.as_deref_mut(), g.motion.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
let mut run = |body: &mut PlayerMotion, keys, sprinting| {
|
||||
|
||||
@@ -38,6 +38,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),
|
||||
self.renderer
|
||||
|
||||
@@ -177,8 +177,22 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
pub(super) fn social_click(&mut self) {
|
||||
let size = self.gui_size();
|
||||
let cursor = self.cursor;
|
||||
let view = self.social_view();
|
||||
let Some(action) = social_screen::click(self.gui_size(), self.cursor, &view) else {
|
||||
let Some(kind) = social_screen::click_kind(size, cursor, &view) else {
|
||||
return;
|
||||
};
|
||||
// The widget first: the search box's `onClick` puts the caret under the
|
||||
// pointer, before any list button is considered.
|
||||
if let social_screen::Click::Search = kind {
|
||||
let shift = self.has_shift_down();
|
||||
self.social
|
||||
.search
|
||||
.click(&self.gui.atlas, cursor.0, shift, false);
|
||||
return;
|
||||
}
|
||||
let social_screen::Click::Action(action) = kind else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
|
||||
@@ -380,6 +380,7 @@ impl<'a> Split<'a> {
|
||||
None,
|
||||
api,
|
||||
batching,
|
||||
self.host.settings.vsync,
|
||||
&self.host.settings.horizon,
|
||||
shader_pack,
|
||||
old.display_reading(),
|
||||
@@ -464,6 +465,7 @@ impl<'a> Split<'a> {
|
||||
None,
|
||||
api,
|
||||
batching,
|
||||
self.host.settings.vsync,
|
||||
&self.host.settings.horizon,
|
||||
shader_pack,
|
||||
old.display_reading(),
|
||||
|
||||
@@ -518,6 +518,7 @@ impl Split<'_> {
|
||||
None,
|
||||
host.settings.horizon.api,
|
||||
host.settings.horizon.translucent_batching,
|
||||
host.settings.vsync,
|
||||
&host.settings.horizon,
|
||||
// The pack its own game loads, from its profile.
|
||||
crate::game::shader_packs::picked_pack_shape(&guest.settings),
|
||||
|
||||
@@ -77,8 +77,9 @@ fn with_stick_table(game: &mut Game<'_>, name: &str) {
|
||||
game.world_root = dir;
|
||||
game.world_tables = game.build_world_tables(false, &[]).unwrap().0;
|
||||
// World open warms the loot data with the world's packs (game.rs); the
|
||||
// break's roll reads that copy.
|
||||
let _ = horizon_client::structure_blocks::world_loot_data(
|
||||
// break's roll reads that copy, here this test's own, so the stick reaches
|
||||
// no other test's stand (#14266).
|
||||
let _ = horizon_client::structure_blocks::load_thread_loot_data(
|
||||
&game.options.resources,
|
||||
&game.pack_files(),
|
||||
);
|
||||
|
||||
@@ -4,26 +4,40 @@ use super::*;
|
||||
fn a_folder_without_prepared_files_is_named_before_loading() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-unprepared-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let missing = missing_resources(&root.join(".local/horizon-client/26.3"))
|
||||
.expect("no manifest is reported");
|
||||
let unprepared = root.join(".local/horizon-client/26.3");
|
||||
let missing = missing_resources(&unprepared, None).expect("no manifest is reported");
|
||||
assert!(missing.contains("horizon-resources.json"), "{missing}");
|
||||
assert!(missing.contains("horizon-unprepared-"), "{missing}");
|
||||
assert!(missing.contains("launcher"), "{missing}");
|
||||
assert!(missing.contains("horizon-prepare --prepare"), "{missing}");
|
||||
// No hook: no site, no launcher.
|
||||
assert!(!missing.contains("playhorizon"), "{missing}");
|
||||
assert!(!missing.contains("launcher"), "{missing}");
|
||||
let get = horizon_hooks::GetTheGame {
|
||||
address: "https://example.org/get",
|
||||
title: "",
|
||||
paragraphs: &[],
|
||||
button: "",
|
||||
};
|
||||
let missing = missing_resources(&unprepared, Some(get)).expect("no manifest is reported");
|
||||
assert!(
|
||||
missing.contains("launcher: download it at https://example.org/get"),
|
||||
"{missing}"
|
||||
);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("horizon-resources.json"), "{}").unwrap();
|
||||
assert_eq!(missing_resources(&root), None);
|
||||
assert_eq!(missing_resources(&root, None), None);
|
||||
|
||||
// A killed export leaves an empty file behind; the error names it
|
||||
// instead of serde's "expected value at line 1 column 1".
|
||||
std::fs::write(root.join("gameplay-facts.json"), "").unwrap();
|
||||
let damaged = missing_resources(&root).expect("an empty prepared file is reported");
|
||||
let damaged = missing_resources(&root, None).expect("an empty prepared file is reported");
|
||||
assert!(damaged.contains("gameplay-facts.json"), "{damaged}");
|
||||
assert!(damaged.contains("damaged"), "{damaged}");
|
||||
std::fs::write(root.join("gameplay-facts.json"), "{}").unwrap();
|
||||
assert_eq!(missing_resources(&root), None);
|
||||
assert_eq!(missing_resources(&root, None), None);
|
||||
|
||||
std::fs::write(root.join("horizon-resources.json"), " \n").unwrap();
|
||||
let damaged = missing_resources(&root).expect("an empty manifest is reported");
|
||||
let damaged = missing_resources(&root, None).expect("an empty manifest is reported");
|
||||
assert!(damaged.contains("horizon-resources.json"), "{damaged}");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
@@ -1351,16 +1351,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
// Entity ticking waits for every chunk within 2 columns
|
||||
// (`ChunkMap.prepareEntityTickingChunk`).
|
||||
for z in -2..=2 {
|
||||
for x in -2..=2 {
|
||||
game.world.chunks.insert(
|
||||
(x, z),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(x, z)),
|
||||
);
|
||||
}
|
||||
}
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
game.streaming.forced.insert((0, 0));
|
||||
Some(game)
|
||||
}
|
||||
@@ -1847,30 +1841,6 @@ mod tests {
|
||||
game.particles.requested.as_ref().unwrap().len()
|
||||
}
|
||||
|
||||
/// #13852: a chunk enters entity ticking only once every chunk within 2
|
||||
/// columns is loaded (`ChunkMap.prepareEntityTickingChunk`), so TNT beside
|
||||
/// a missing chunk keeps its fuse until the neighbourhood is whole.
|
||||
#[test]
|
||||
fn tnt_waits_while_a_chunk_two_columns_away_is_missing() {
|
||||
let Some(mut game) = server_game() else {
|
||||
return;
|
||||
};
|
||||
game.world.chunks.remove(&(2, 0));
|
||||
game.add_primed_tnt(BlockPos::new(8, 64, 8), 0.0, 80, None, None);
|
||||
game.tnts[0].no_gravity = true;
|
||||
game.tnts[0].velocity = Vec3::ZERO;
|
||||
for _ in 0..3 {
|
||||
game.tick_world(true).unwrap();
|
||||
}
|
||||
assert_eq!(game.tnts[0].fuse, 80, "no entity ticking yet");
|
||||
game.world.chunks.insert(
|
||||
(2, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(2, 0)),
|
||||
);
|
||||
game.tick_world(true).unwrap();
|
||||
assert!(game.tnts[0].fuse < 80, "ticks once the chunk is back");
|
||||
}
|
||||
|
||||
/// #10063: TNT lit between level ticks (flint and steel, a command) has its
|
||||
/// first tick in the next level tick, so its fuse reads 79 after it, and it
|
||||
/// explodes on the 80th (`PrimedTnt.tick`). Horizon held every TNT that
|
||||
|
||||
@@ -157,11 +157,8 @@ impl Hub {
|
||||
};
|
||||
if ac.config.runs(Check::Prediction) {
|
||||
let g = &mut self.guests[i];
|
||||
let (input, sprinting) = (g.input, g.anticheat.sprinting);
|
||||
g.anticheat
|
||||
.record
|
||||
.get_or_insert_default()
|
||||
.tick_end(input, sprinting);
|
||||
let (input, sprinting) = (g.input, g.client_sprinting);
|
||||
g.record.get_or_insert_default().tick_end(input, sprinting);
|
||||
}
|
||||
if !ac.config.runs(Check::Timer) {
|
||||
return;
|
||||
@@ -249,7 +246,7 @@ impl Hub {
|
||||
flags: u8,
|
||||
) {
|
||||
if self.recording() {
|
||||
let record = self.guests[i].anticheat.record.get_or_insert_default();
|
||||
let record = self.guests[i].record.get_or_insert_default();
|
||||
record.moved(pos, rot, flags);
|
||||
}
|
||||
}
|
||||
@@ -259,7 +256,7 @@ impl Hub {
|
||||
pub(super) fn anticheat_pong(&mut self, i: usize, id: i32) {
|
||||
let ticks = self.ticks;
|
||||
if self.recording() {
|
||||
let record = self.guests[i].anticheat.record.get_or_insert_default();
|
||||
let record = self.guests[i].record.get_or_insert_default();
|
||||
// Ids are server ticks, cut to 32 bits.
|
||||
record.pong(u64::from(id as u32), ticks);
|
||||
}
|
||||
@@ -280,7 +277,7 @@ impl Hub {
|
||||
.get_mut()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
||||
);
|
||||
if let Some(record) = g.anticheat.record.as_deref_mut() {
|
||||
if let Some(record) = g.record.as_deref_mut() {
|
||||
for change in changes {
|
||||
record.changed(ticks, change);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! (`data/minecraft/...`) and the registries report.
|
||||
use crate::chunk::LevelShapes;
|
||||
use horizon_protocol::{
|
||||
entity_data,
|
||||
packets::configuration::{RegistryData, RegistryEntry, RegistryTags, TagEntry, UpdateTags},
|
||||
registry::{Builtin, Dynamic},
|
||||
};
|
||||
@@ -507,58 +506,6 @@ impl SyncData {
|
||||
.any(|(_, e)| e.from_pack.contains(&true))
|
||||
}
|
||||
|
||||
/// `LivingEntity.updateSynchronizedMobEffectParticles`: DATA_EFFECT_PARTICLES
|
||||
/// (10) and DATA_EFFECT_AMBIENCE_ID (11) for the visible `effects`, as
|
||||
/// (effect, ambient). The entries are left out when there is nothing to
|
||||
/// show, which is their default.
|
||||
pub fn effect_meta<'a>(
|
||||
&self,
|
||||
effects: impl IntoIterator<Item = (&'a str, bool)>,
|
||||
) -> Vec<(u8, i32, entity_data::Value)> {
|
||||
let mut particles = Vec::new();
|
||||
let mut all_ambient = true;
|
||||
for (effect, ambient) in effects {
|
||||
let effect = effect.trim_start_matches("minecraft:");
|
||||
// MobEffect.particleFactory: the omens have their own particles,
|
||||
// the others an entity effect in their colour (faint if ambient).
|
||||
let (name, data) = match effect {
|
||||
"trial_omen" | "raid_omen" => (effect, None),
|
||||
_ => {
|
||||
let Some(def) = crate::brewing::effect(effect) else {
|
||||
continue;
|
||||
};
|
||||
let alpha = if ambient { 38 } else { 255 };
|
||||
let argb = (alpha << 24) | def.shown_color() as i32;
|
||||
(
|
||||
"entity_effect",
|
||||
Some(entity_data::ParticleData::Color(argb)),
|
||||
)
|
||||
}
|
||||
};
|
||||
let Some(id) = self.id("minecraft:particle_type", &format!("minecraft:{name}")) else {
|
||||
continue;
|
||||
};
|
||||
particles.push(entity_data::Particle { id, data });
|
||||
all_ambient &= ambient;
|
||||
}
|
||||
if particles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut meta = vec![(
|
||||
10,
|
||||
entity_data::serializer::PARTICLES,
|
||||
entity_data::Value::Particles(particles),
|
||||
)];
|
||||
if all_ambient {
|
||||
meta.push((
|
||||
11,
|
||||
entity_data::serializer::BOOLEAN,
|
||||
entity_data::Value::Bool(true),
|
||||
));
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// The id of a name in a built-in or synchronized registry.
|
||||
pub fn id(&self, registry: &str, name: &str) -> Option<i32> {
|
||||
if registry == "minecraft:data_component_type"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! A remote player: connection, position, chunk and entity views, and the
|
||||
//! player state (inventory, health, game mode) that the host's gameplay code
|
||||
//! operates on when it acts for them.
|
||||
use super::prediction::record::{Change, Record};
|
||||
use super::{Joined, PlayerSender, entity::Tracker};
|
||||
use crate::{
|
||||
items::ItemStack,
|
||||
@@ -221,10 +222,19 @@ pub struct Guest {
|
||||
pub position_this_tick: bool,
|
||||
/// Violation levels and the checks' state (the anticheat, when on).
|
||||
pub anticheat: horizon_anticheat::Player,
|
||||
/// The client ticks the movement prediction plays again (only while it records).
|
||||
pub record: Option<Box<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 client_sprinting: bool,
|
||||
/// The movement prediction's replay of the client's own moves (only while it runs).
|
||||
pub replay: Option<Box<super::prediction::Replay>>,
|
||||
/// What the server sent this tick that changes how the client moves (only while it records).
|
||||
pub changes: std::sync::Mutex<Vec<horizon_anticheat::prediction::Change>>,
|
||||
pub changes: std::sync::Mutex<Vec<Change>>,
|
||||
/// It accepted a move since the last entity range checks
|
||||
/// (ServerChunkCache.move), which then check all of its pairs again.
|
||||
pub moved: bool,
|
||||
@@ -515,6 +525,8 @@ impl Guest {
|
||||
support_blocks_sent: 0,
|
||||
position_this_tick: false,
|
||||
anticheat: Default::default(),
|
||||
record: None,
|
||||
client_sprinting: false,
|
||||
replay: None,
|
||||
changes: Default::default(),
|
||||
moved: false,
|
||||
@@ -626,7 +638,7 @@ impl Guest {
|
||||
if self.fake && !super::conn::fake_wants_id(packet.id()) {
|
||||
return;
|
||||
}
|
||||
if self.anticheat.record.is_some() {
|
||||
if self.record.is_some() {
|
||||
self.note_change(packet);
|
||||
}
|
||||
self.sender.send(packet.encode());
|
||||
@@ -653,7 +665,6 @@ impl Guest {
|
||||
/// What `packet` changes about how the client moves, for the prediction's replay: kept until
|
||||
/// the tick's ping places it (`Hub::anticheat_ping`).
|
||||
fn note_change(&self, packet: &play::Clientbound) {
|
||||
use horizon_anticheat::prediction::Change;
|
||||
use play::Clientbound as C;
|
||||
let own = |id: i32| id == self.entity_id;
|
||||
let change = match packet {
|
||||
|
||||
@@ -24,7 +24,7 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_protocol::ids::play::clientbound as ids;
|
||||
use horizon_protocol::{
|
||||
entity_data::{Value, serializer},
|
||||
entity_data::{Particle, ParticleData, Value, serializer},
|
||||
packets::play::{self, Clientbound as C, Serverbound as S},
|
||||
text::{literal, translatable},
|
||||
};
|
||||
@@ -3113,7 +3113,7 @@ impl Hub {
|
||||
// changed, so the one nearest the player stands for them.
|
||||
for &i in &viewers {
|
||||
let g = &mut self.guests[i];
|
||||
let Some(record) = g.anticheat.record.as_deref_mut() else {
|
||||
let Some(record) = g.record.as_deref_mut() else {
|
||||
continue;
|
||||
};
|
||||
let slack = super::prediction::slack(std::array::from_fn(|i| {
|
||||
@@ -3197,7 +3197,7 @@ impl Hub {
|
||||
if recording {
|
||||
for &i in &viewers {
|
||||
let g = &mut self.guests[i];
|
||||
let Some(record) = g.anticheat.record.as_deref_mut() else {
|
||||
let Some(record) = g.record.as_deref_mut() else {
|
||||
continue;
|
||||
};
|
||||
let slack = super::prediction::slack(std::array::from_fn(|i| {
|
||||
@@ -3250,12 +3250,39 @@ impl Hub {
|
||||
&self,
|
||||
effects: impl IntoIterator<Item = (&'a str, bool)>,
|
||||
) -> Vec<(u8, i32, Value)> {
|
||||
self.sync.effect_meta(effects)
|
||||
}
|
||||
|
||||
/// The registries and tables the clients are synced with (read by helper threads).
|
||||
pub fn sync_data(&self) -> &SyncData {
|
||||
&self.sync
|
||||
let mut particles = Vec::new();
|
||||
let mut all_ambient = true;
|
||||
for (effect, ambient) in effects {
|
||||
let effect = effect.trim_start_matches("minecraft:");
|
||||
// MobEffect.particleFactory: the omens have their own particles,
|
||||
// the others an entity effect in their colour (faint if ambient).
|
||||
let (name, data) = match effect {
|
||||
"trial_omen" | "raid_omen" => (effect, None),
|
||||
_ => {
|
||||
let Some(def) = crate::brewing::effect(effect) else {
|
||||
continue;
|
||||
};
|
||||
let alpha = if ambient { 38 } else { 255 };
|
||||
let argb = (alpha << 24) | def.shown_color() as i32;
|
||||
("entity_effect", Some(ParticleData::Color(argb)))
|
||||
}
|
||||
};
|
||||
let Some(id) =
|
||||
self.registry_id("minecraft:particle_type", &format!("minecraft:{name}"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
particles.push(Particle { id, data });
|
||||
all_ambient &= ambient;
|
||||
}
|
||||
if particles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut meta = vec![(10, serializer::PARTICLES, Value::Particles(particles))];
|
||||
if all_ambient {
|
||||
meta.push((11, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
fn player_state(
|
||||
@@ -3561,7 +3588,7 @@ impl Hub {
|
||||
}));
|
||||
for at in gone.drain(..) {
|
||||
let block = at.map(|v| v.floor() as i32);
|
||||
if let Some(record) = g.anticheat.record.as_deref_mut()
|
||||
if let Some(record) = g.record.as_deref_mut()
|
||||
&& super::prediction::near(g.position, block, slack)
|
||||
{
|
||||
record.entity_gone(ticks);
|
||||
@@ -3963,7 +3990,7 @@ impl Hub {
|
||||
fn set_sprinting(&mut self, i: usize, sprinting: bool) {
|
||||
let g = &mut self.guests[i];
|
||||
g.sprinting = sprinting;
|
||||
g.anticheat.sprinting = sprinting;
|
||||
g.client_sprinting = sprinting;
|
||||
if let Some(m) = g.motion.as_mut() {
|
||||
let _ = m.set_sprint_modifier(sprinting);
|
||||
}
|
||||
@@ -4051,10 +4078,7 @@ impl Hub {
|
||||
// The prediction's replay starts again where the client now is.
|
||||
if recording {
|
||||
let kept = g.awaiting_keeps_motion;
|
||||
g.anticheat
|
||||
.record
|
||||
.get_or_insert_default()
|
||||
.teleported(at, kept);
|
||||
g.record.get_or_insert_default().teleported(at, kept);
|
||||
}
|
||||
let rot = (p.yaw, p.pitch);
|
||||
self.position_change(i, [p.x, p.y, p.z], rot, 0, ctx, events);
|
||||
@@ -5040,7 +5064,7 @@ impl Hub {
|
||||
// ClientPacketListener.handleRespawn without KEEP_ENTITY_DATA: the client's new player
|
||||
// starts with wasSprinting false. (A level change keeps it: the new player sprints on, or
|
||||
// says STOP.)
|
||||
g.anticheat.sprinting = false;
|
||||
g.client_sprinting = false;
|
||||
// The respawned player is a new one, without effects; so is the
|
||||
// client's (ClientPacketListener.handleRespawn).
|
||||
g.effects = Default::default();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The movement prediction's replay (docs/anticheat.md): each client tick a player's record holds
|
||||
//! ([`horizon_anticheat::prediction::Record`]) is played again with the client's own movement code,
|
||||
//! ([`Record`]) is played again with the client's own movement code,
|
||||
//! from where the last one ended, and compared with where the client said it went.
|
||||
//!
|
||||
//! The inputs are known exactly: a vanilla client sends ServerboundPlayerInputPacket after its tick's
|
||||
@@ -21,8 +21,10 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use horizon_anticheat::prediction::{Change, ClientTick, KEPT, Record, Restart};
|
||||
pub mod record;
|
||||
|
||||
use horizon_core::geometry::Vec3;
|
||||
use record::{Change, ClientTick, KEPT, Record, Restart};
|
||||
|
||||
use crate::motion::PlayerMotion;
|
||||
use crate::sprint::Keys;
|
||||
@@ -650,13 +652,13 @@ fn sync(body: &mut PlayerMotion, server: &PlayerMotion) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::record::Record;
|
||||
use super::*;
|
||||
use crate::{
|
||||
assets::Assets,
|
||||
motion::{MotionResources, MotionWorld},
|
||||
world::World,
|
||||
};
|
||||
use horizon_anticheat::prediction::Record;
|
||||
use std::path::Path;
|
||||
|
||||
/// Keys for each tick: 20 walking, 12 sprint-jumping, 12 strafing while turning, 6 still.
|
||||
|
||||
File renamed without changes.
@@ -5,10 +5,6 @@
|
||||
//! fly and amphibious node evaluators (`path`, `nav`), move/look/jump/body
|
||||
//! controls (`control`), `LivingEntity` physics (`living`) and per-species
|
||||
//! behaviour, models, animations and sounds.
|
||||
mod mob_slot;
|
||||
mod pending_pushes;
|
||||
pub use mob_slot::MobSlot;
|
||||
pub use pending_pushes::{PendingPushes, apply_mob_pushes};
|
||||
pub mod ai;
|
||||
pub mod allay;
|
||||
pub mod ambient;
|
||||
@@ -52,6 +48,7 @@ pub mod golem;
|
||||
pub mod guardian;
|
||||
pub mod happy_ghast;
|
||||
pub mod hostile;
|
||||
mod hurt_by;
|
||||
pub mod illager;
|
||||
pub mod illusioner;
|
||||
pub mod item_break;
|
||||
@@ -2927,6 +2924,28 @@ pub fn take_mob_pushes(events: &mut Vec<MobEvent>, from: usize, pending: &mut Pe
|
||||
}
|
||||
events.truncate(kept);
|
||||
}
|
||||
/// Pushes waiting for their mob's tick, in push order. A list while short;
|
||||
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
|
||||
/// per mob, as scanning the whole list for each mob grows with its square.
|
||||
#[derive(Default)]
|
||||
pub struct PendingPushes {
|
||||
list: Vec<(u32, Vec3)>,
|
||||
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
|
||||
}
|
||||
impl PendingPushes {
|
||||
const LIST_MAX: usize = 64;
|
||||
pub fn push(&mut self, id: u32, v: Vec3) {
|
||||
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
|
||||
self.list.push((id, v));
|
||||
return;
|
||||
}
|
||||
// Older pushes first, so each mob's stay in order.
|
||||
for (id, v) in self.list.drain(..) {
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.sendBlockUpdated`: for each write that changed a block's collision
|
||||
/// shape, a mob whose path runs near it re-paths (`PathNavigation.shouldRecomputePath`).
|
||||
/// The writes whose shape changed are found once per tick ([`collision_changes`]).
|
||||
@@ -2954,28 +2973,50 @@ pub fn collision_changes(w: &MobWorld, writes: &[((i32, i32, i32), u32, u32)]) -
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
|
||||
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
|
||||
// Entity.push(x, y, z) sets needsSync.
|
||||
let mut add = |v: Vec3| {
|
||||
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
|
||||
m.needs_sync = true;
|
||||
};
|
||||
if !pending.list.is_empty() {
|
||||
pending.list.retain(|(id, v)| {
|
||||
if *id != m.id {
|
||||
return true;
|
||||
}
|
||||
add(*v);
|
||||
false
|
||||
});
|
||||
}
|
||||
if !pending.by_mob.is_empty()
|
||||
&& let Some(pushes) = pending.by_mob.remove(&m.id)
|
||||
{
|
||||
pushes.into_iter().for_each(add);
|
||||
}
|
||||
}
|
||||
/// Applies the brain memories a mob's tick set on other mobs (from event
|
||||
/// index `from` on), at once: vanilla sets them on the other entity during
|
||||
/// the tick, before the mobs after it tick.
|
||||
pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>, from: usize) {
|
||||
pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: usize) {
|
||||
let mut i = from;
|
||||
while i < events.len() {
|
||||
match events[i] {
|
||||
MobEvent::BreedTarget { mob, partner } => {
|
||||
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
|
||||
animal_brain::set_breed_partner(m, partner);
|
||||
}
|
||||
events.remove(i);
|
||||
}
|
||||
MobEvent::LockGaze { mob, partner } => {
|
||||
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
|
||||
animal_brain::lock_gaze(m, partner);
|
||||
villager_brain::lock_gaze_half(m, Who::Mob(partner));
|
||||
}
|
||||
events.remove(i);
|
||||
}
|
||||
MobEvent::Retreat { mob, from, at } => {
|
||||
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
|
||||
piglin::receive_retreat(m, from, at);
|
||||
}
|
||||
events.remove(i);
|
||||
@@ -2989,11 +3030,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
|
||||
events.remove(i);
|
||||
}
|
||||
MobEvent::SchoolSize { leader, by } => {
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(MobSlot::mob_mut)
|
||||
.find(|m| m.id == leader)
|
||||
{
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
|
||||
m.extra.followers += by;
|
||||
}
|
||||
events.remove(i);
|
||||
@@ -3003,11 +3040,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
|
||||
MobEvent::Breed {
|
||||
parent, partner, ..
|
||||
} => {
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(MobSlot::mob_mut)
|
||||
.find(|m| m.id == partner)
|
||||
{
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == partner) {
|
||||
m.extra.age = 6000;
|
||||
m.extra.in_love = 0;
|
||||
animal_brain::forget_breed_target(m, parent);
|
||||
@@ -3029,7 +3062,6 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
|
||||
let mut raised = Vec::new();
|
||||
let (aim, fish_pos, fish_silent) = mobs
|
||||
.iter()
|
||||
.map(MobSlot::mob)
|
||||
.find(|m| m.id == mob)
|
||||
.map(|m| {
|
||||
(
|
||||
@@ -3044,10 +3076,7 @@ pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>
|
||||
})
|
||||
.map(|(aim, pos, silent)| (Some(aim), Some(pos), silent))
|
||||
.unwrap_or((None, None, true));
|
||||
if let Some(t) = mobs
|
||||
.iter_mut()
|
||||
.map(MobSlot::mob_mut)
|
||||
.find(|m| m.id == target)
|
||||
if let Some(t) = mobs.iter_mut().find(|m| m.id == target)
|
||||
&& t.damage_aimed(amount, source, aim)
|
||||
{
|
||||
t.add_effect("minecraft:poison", poison_ticks, 0);
|
||||
@@ -3262,9 +3291,6 @@ pub struct MobWorld<'a> {
|
||||
/// not in the level (a warden's anger, `AngerManagement.angerByUuid`).
|
||||
/// None: this world keeps no entity UUIDs, so nothing is resolved.
|
||||
pub who_lookup: Option<&'a dyn WhoLookup>,
|
||||
/// Another thread's mobs, for a lookup by id that misses `others`: a group of the server's
|
||||
/// parallel mob loop (`game/mob_islands.rs`) answers as the serial loop would.
|
||||
pub foreign: Option<&'a dyn ForeignSeen>,
|
||||
/// The level's world border, whose walls stop a mob's moves
|
||||
/// (`Entity.collectColliders`); None: nothing stops them there.
|
||||
pub border: Option<&'a horizon_core::geometry::border::BorderGeometry>,
|
||||
@@ -3279,10 +3305,6 @@ pub trait ModEffectTicks {
|
||||
/// names while it is in the level, never some other entity as a stand-in. Only
|
||||
/// the game layer keeps entity UUIDs, so it answers this (`Game::who_by_uuid`)
|
||||
/// and the mob layer reaches it through `MobWorld`.
|
||||
/// A mob outside this thread's part of the mob loop, as the serial loop would see it now.
|
||||
pub trait ForeignSeen {
|
||||
fn seen(&self, id: u32) -> Option<&Seen>;
|
||||
}
|
||||
pub trait WhoLookup {
|
||||
fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who>;
|
||||
}
|
||||
@@ -3489,7 +3511,7 @@ impl Mob {
|
||||
/// something off its pickup cooldown, a sulfur cube with no block.
|
||||
pub fn can_pick_up_loot(&self) -> bool {
|
||||
match self.species() {
|
||||
Species::Allay => self.extra.main_hand.is_some() && self.extra.ints[3] <= 0,
|
||||
Species::Allay => allay::can_pick_up_loot(self),
|
||||
Species::SulfurCube => !sulfur_cube::has_body(self),
|
||||
_ => self.extra.pick_up_loot,
|
||||
}
|
||||
@@ -3916,8 +3938,7 @@ impl Mob {
|
||||
self.last_damage = Some((source, i64::from(self.age)));
|
||||
self.last_direct = None;
|
||||
if let Some(who) = source.attacker {
|
||||
self.ai.last_hurt_by = Some(who);
|
||||
self.ai.last_hurt_by_time = self.age as i32;
|
||||
self.ai.hurt_by(who, self.age as i32);
|
||||
if who == Who::Player {
|
||||
self.ai.last_hurt_by_player = 100;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,8 @@ pub struct Ai {
|
||||
pub target: Option<Who>,
|
||||
pub last_hurt_by: Option<Who>,
|
||||
pub last_hurt_by_time: i32,
|
||||
/// The bare UUID of an unresolved `lastHurtByMob` (see `hurt_by`).
|
||||
pub last_hurt_by_ref: Option<[i32; 4]>,
|
||||
/// Ticks left during which kills count as player kills.
|
||||
pub last_hurt_by_player: i32,
|
||||
pub no_action_time: i32,
|
||||
@@ -624,7 +626,7 @@ fn drop_invalid_player_target(m: &mut Mob, w: &MobWorld) {
|
||||
m.ai.target = None;
|
||||
if matches!(m.species(), Species::ZombifiedPiglin | Species::IronGolem) {
|
||||
super::neutral_anger::stop(m);
|
||||
m.ai.last_hurt_by = None;
|
||||
m.ai.forget_hurt_by(m.ai.last_hurt_by_time);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1357,12 +1359,7 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
|
||||
if matches!(m.species(), Species::Evoker | Species::Illusioner) {
|
||||
super::illager::cast_particles(m, events);
|
||||
}
|
||||
if m.hurt_time > 0 {
|
||||
m.hurt_time -= 1;
|
||||
}
|
||||
if m.invulnerable > 0 {
|
||||
m.invulnerable -= 1;
|
||||
}
|
||||
super::hurt_by::base_tick(m, w);
|
||||
// LivingEntity.baseTick runs tickEffects after the timers count down.
|
||||
tick_effects(m, w.mod_effects, &mut |m| hurt_teleports(m, w, events));
|
||||
// Entity.applyEffectsFromBlocks runs in LivingEntity.aiStep, after
|
||||
@@ -2957,8 +2954,7 @@ pub fn forgive_dead_player(m: &mut Mob) {
|
||||
// is the player who died, the last hurt by with it.
|
||||
Species::Enderman | Species::ZombifiedPiglin => {
|
||||
if m.ai.target == Some(Who::Player) {
|
||||
m.ai.last_hurt_by = None;
|
||||
m.ai.last_hurt_by_time = m.age as i32;
|
||||
m.ai.forget_hurt_by(m.age as i32);
|
||||
m.ai.target = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,25 @@ pub mod interaction;
|
||||
// Allay state: `main_hand` the held filter; `State::inventory` the fetched stack,
|
||||
// `tame` a liked player, `point` the wanted item (set by the game),
|
||||
// `State::noteblock` the liked note block with `ints[2]` its cooldown,
|
||||
// `ints[3]` the pickup cooldown, `flag` dancing with `ints[4]` the dance clock,
|
||||
// `pickup_cooldown` the pickup cooldown, `flag` dancing with `ints[4]` the dance clock,
|
||||
// `egg_time` the duplication cooldown. AnimState slots: holding (0),
|
||||
// spinning (1).
|
||||
/// `ITEM_PICKUP_COOLDOWN_TICKS`. `CountDownCooldownTicks` counts it down while it is above 0 and
|
||||
/// erases it the tick after it reached 0 (`stop`), so a memory at 0 is still
|
||||
/// there for a tick, and `Allay.isOnPickupCooldown` is the memory's presence.
|
||||
pub fn pickup_cooldown(m: &Mob) -> Option<i32> {
|
||||
m.extra.allay.as_ref()?.pickup_cooldown
|
||||
}
|
||||
/// `Allay.canPickUpLoot`: a held item and no pickup-cooldown memory.
|
||||
pub fn can_pick_up_loot(m: &Mob) -> bool {
|
||||
m.extra.main_hand.is_some() && pickup_cooldown(m).is_none()
|
||||
}
|
||||
/// Set or erase `ITEM_PICKUP_COOLDOWN_TICKS` (see [`pickup_cooldown`]).
|
||||
pub fn set_pickup_cooldown(m: &mut Mob, ticks: Option<i32>) {
|
||||
if let Some(state) = m.extra.allay.as_mut() {
|
||||
state.pickup_cooldown = ticks;
|
||||
}
|
||||
}
|
||||
pub const HOLD: usize = 0;
|
||||
pub const SPIN: usize = 1;
|
||||
/// `Allay.isLikedPlayer`: compare the supplied verified player UUID with
|
||||
@@ -157,6 +173,8 @@ pub fn pickup_reach(bounds: Aabb) -> Aabb {
|
||||
pub struct State {
|
||||
/// Allay's separate one-slot SimpleContainer.
|
||||
pub inventory: Option<ItemStack>,
|
||||
/// The full Codec.INT value, including zero while the memory is present.
|
||||
pub pickup_cooldown: Option<i32>,
|
||||
pub vibration: sculk::Data,
|
||||
pub noteblock: Option<GlobalPos>,
|
||||
/// The `LIKED_PLAYER` memory's time to live (`MemorySlot.timeToLive`);
|
||||
@@ -326,7 +344,7 @@ impl Goal for Fetch {
|
||||
MOVE
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
|
||||
m.extra.point.is_some() && m.extra.ints[3] <= 0
|
||||
m.extra.point.is_some() && pickup_cooldown(m).is_none()
|
||||
}
|
||||
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
if let Some(p) = m.extra.point
|
||||
@@ -491,11 +509,12 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
|
||||
tick_liked_player(m);
|
||||
passive_heal(m);
|
||||
stop_dancing_away(m, w);
|
||||
for i in [2, 3] {
|
||||
if m.extra.ints[i] > 0 {
|
||||
m.extra.ints[i] -= 1;
|
||||
}
|
||||
if m.extra.ints[2] > 0 {
|
||||
m.extra.ints[2] -= 1;
|
||||
}
|
||||
// CountDownCooldownTicks stops and erases a nonpositive memory; a positive
|
||||
// value reaches zero for one tick before that stop.
|
||||
set_pickup_cooldown(m, pickup_cooldown(m).filter(|t| *t > 0).map(|t| t - 1));
|
||||
update_duplication_cooldown(m);
|
||||
animate(m);
|
||||
}
|
||||
|
||||
@@ -794,35 +794,18 @@ fn can_be_followed(s: Species, followers: i32) -> bool {
|
||||
}
|
||||
/// `startFollowing` on a recruit, applied after the recruiter's tick. A fish
|
||||
/// another leader took this tick (its snapshot was older) stays with it.
|
||||
pub fn start_following<M: super::MobSlot>(mobs: &mut [M], fish: u32, leader: u32) {
|
||||
let alive = |mobs: &[M], id: u32| {
|
||||
mobs.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.any(|m| m.id == id && m.alive() && !m.removed)
|
||||
};
|
||||
let Some(current) = mobs
|
||||
.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.find(|m| m.id == fish)
|
||||
.map(|m| m.extra.leader)
|
||||
else {
|
||||
pub fn start_following(mobs: &mut [Mob], fish: u32, leader: u32) {
|
||||
let alive = |mobs: &[Mob], id: u32| mobs.iter().any(|m| m.id == id && m.alive() && !m.removed);
|
||||
let Some(current) = mobs.iter().find(|m| m.id == fish).map(|m| m.extra.leader) else {
|
||||
return;
|
||||
};
|
||||
if current.is_some_and(|l| alive(mobs, l)) {
|
||||
return;
|
||||
}
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(super::MobSlot::mob_mut)
|
||||
.find(|m| m.id == fish)
|
||||
{
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == fish) {
|
||||
m.extra.leader = Some(leader);
|
||||
}
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(super::MobSlot::mob_mut)
|
||||
.find(|m| m.id == leader)
|
||||
{
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
|
||||
m.extra.followers += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ pub fn angry(m: &Mob, w: &MobWorld) -> bool {
|
||||
/// `NeutralMob.stopBeingAngry`.
|
||||
pub fn stop_being_angry(m: &mut Mob) {
|
||||
m.ai.last_hurt_by = None;
|
||||
m.ai.last_hurt_by_ref = None;
|
||||
m.ai.last_hurt_by_time = m.age as i32;
|
||||
m.ai.target = None;
|
||||
let b = st(m);
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct Cx<'a, 'w> {
|
||||
pub lr: &'a mut LegacyRandom,
|
||||
pub events: &'a mut Vec<MobEvent>,
|
||||
}
|
||||
pub trait Goal: std::fmt::Debug + Send + Sync {
|
||||
pub trait Goal: std::fmt::Debug {
|
||||
fn flags(&self) -> u8;
|
||||
/// A goal that draws its first start delay in its constructor (a schooling fish's
|
||||
/// `FollowFlockLeaderGoal`): the delay again, when the mob's random is seeded afterwards.
|
||||
|
||||
@@ -48,8 +48,7 @@ impl Goal for ResetUniversalAnger {
|
||||
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
|
||||
self.timestamp = m.ai.last_hurt_by_time;
|
||||
// setLastHurtByMob(null) also stamps the time.
|
||||
m.ai.last_hurt_by = None;
|
||||
m.ai.last_hurt_by_time = m.age as i32;
|
||||
m.ai.forget_hurt_by(m.age as i32);
|
||||
m.ai.target = None;
|
||||
m.extra.angry_at = None;
|
||||
// `TimeUtil.rangeOfSeconds(20, 39)`.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! The mob that last hurt a mob (`LivingEntity.lastHurtByMob`): its record in
|
||||
//! `Ai`, its end in `baseTick`, and what a hit or a stop being angry does to it.
|
||||
use super::ai::Ai;
|
||||
use super::{Mob, MobWorld, Who};
|
||||
|
||||
// `LivingEntity.lastHurtByMob` is a lazy `EntityReference`: a record read back
|
||||
// from `last_hurt_by_mob` stays the bare UUID (`Ai::last_hurt_by_ref`) until the
|
||||
// game resolves it into `last_hurt_by`. `getLastHurtByMob` answers nothing for
|
||||
// it and `baseTick` does not end it; its timestamp keeps aging.
|
||||
impl Ai {
|
||||
/// `LivingEntity.setLastHurtByMob(who)`: the new record replaces a
|
||||
/// reference read back that was not resolved.
|
||||
pub fn hurt_by(&mut self, who: Who, tick: i32) {
|
||||
self.last_hurt_by = Some(who);
|
||||
self.last_hurt_by_ref = None;
|
||||
self.last_hurt_by_time = tick;
|
||||
}
|
||||
/// `LivingEntity.setLastHurtByMob(null)`, which also stamps the time.
|
||||
pub fn forget_hurt_by(&mut self, tick: i32) {
|
||||
self.last_hurt_by = None;
|
||||
self.last_hurt_by_ref = None;
|
||||
self.last_hurt_by_time = tick;
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.baseTick`'s hurt timers: `hurtTime` and `invulnerableTime`
|
||||
/// count down, and the mob that hurt it is forgotten once it is not alive or
|
||||
/// after more than 100 ticks. One that is not in the level
|
||||
/// (`getLastHurtByMob` is null) goes on.
|
||||
pub(super) fn base_tick(m: &mut Mob, w: &MobWorld) {
|
||||
if m.hurt_time > 0 {
|
||||
m.hurt_time -= 1;
|
||||
}
|
||||
if m.invulnerable > 0 {
|
||||
m.invulnerable -= 1;
|
||||
}
|
||||
if let Some(who) = m.ai.last_hurt_by
|
||||
&& let Some(by) = w.target_info(who)
|
||||
&& (!by.alive || (m.age as i32).wrapping_sub(m.ai.last_hurt_by_time) > 100)
|
||||
{
|
||||
m.ai.forget_hurt_by(m.age as i32);
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,6 @@ fn a_mob_bounces_off_a_bouncy_block_and_not_off_stone() {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
@@ -292,7 +291,6 @@ fn a_ridden_horse_floats_in_deep_water() {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
@@ -413,7 +411,6 @@ fn a_falling_mob_lands_on_a_shulker_or_a_held_happy_ghast() {
|
||||
nearest_player: None,
|
||||
others: &others,
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
//! [`MobSlot`]: a mob a list hands out.
|
||||
use super::Mob;
|
||||
|
||||
/// A mob a list hands out: the list's own, or a borrow of one in a larger list (a group of the
|
||||
/// server's parallel mob loop, `game/mob_islands.rs`, works on its mobs in place).
|
||||
pub trait MobSlot {
|
||||
fn mob(&self) -> &Mob;
|
||||
fn mob_mut(&mut self) -> &mut Mob;
|
||||
}
|
||||
impl MobSlot for Mob {
|
||||
#[inline]
|
||||
fn mob(&self) -> &Mob {
|
||||
self
|
||||
}
|
||||
#[inline]
|
||||
fn mob_mut(&mut self) -> &mut Mob {
|
||||
self
|
||||
}
|
||||
}
|
||||
impl MobSlot for &mut Mob {
|
||||
#[inline]
|
||||
fn mob(&self) -> &Mob {
|
||||
self
|
||||
}
|
||||
#[inline]
|
||||
fn mob_mut(&mut self) -> &mut Mob {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//! The pushes a mob's tick gives others, waiting for each one's own tick.
|
||||
use super::Mob;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// Pushes waiting for their mob's tick, in push order. A list while short;
|
||||
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
|
||||
/// per mob, as scanning the whole list for each mob grows with its square.
|
||||
#[derive(Default)]
|
||||
pub struct PendingPushes {
|
||||
list: Vec<(u32, Vec3)>,
|
||||
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
|
||||
}
|
||||
impl PendingPushes {
|
||||
const LIST_MAX: usize = 64;
|
||||
/// `other`'s pushes after these, each mob's in their order.
|
||||
pub fn append(&mut self, other: PendingPushes) {
|
||||
for (id, v) in other.list {
|
||||
self.push(id, v);
|
||||
}
|
||||
for (id, list) in other.by_mob {
|
||||
for v in list {
|
||||
self.push(id, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn push(&mut self, id: u32, v: Vec3) {
|
||||
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
|
||||
self.list.push((id, v));
|
||||
return;
|
||||
}
|
||||
// Older pushes first, so each mob's stay in order.
|
||||
for (id, v) in self.list.drain(..) {
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
}
|
||||
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
|
||||
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
|
||||
// Entity.push(x, y, z) sets needsSync.
|
||||
let mut add = |v: Vec3| {
|
||||
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
|
||||
m.needs_sync = true;
|
||||
};
|
||||
if !pending.list.is_empty() {
|
||||
pending.list.retain(|(id, v)| {
|
||||
if *id != m.id {
|
||||
return true;
|
||||
}
|
||||
add(*v);
|
||||
false
|
||||
});
|
||||
}
|
||||
if !pending.by_mob.is_empty()
|
||||
&& let Some(pushes) = pending.by_mob.remove(&m.id)
|
||||
{
|
||||
pushes.into_iter().for_each(add);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PendingPushes;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// Each mob's pushes, in the order they apply.
|
||||
fn per_mob(p: &PendingPushes) -> std::collections::BTreeMap<u32, Vec<[f64; 3]>> {
|
||||
let mut out = std::collections::BTreeMap::<u32, Vec<[f64; 3]>>::new();
|
||||
for (id, v) in &p.list {
|
||||
out.entry(*id).or_default().push([v.x, v.y, v.z]);
|
||||
}
|
||||
for (id, list) in &p.by_mob {
|
||||
out.entry(*id)
|
||||
.or_default()
|
||||
.extend(list.iter().map(|v| [v.x, v.y, v.z]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Two lists of pushes, the second appended to the first, apply as one list of them all in
|
||||
/// that order, on either side of the switch from one list to a list per mob.
|
||||
#[test]
|
||||
fn appended_pushes_apply_in_order() {
|
||||
let pushes: Vec<(u32, Vec3)> = (0..200u32)
|
||||
.map(|k| (k * 7 % 11, Vec3::new(f64::from(k), 0.0, -f64::from(k))))
|
||||
.collect();
|
||||
let mut all = PendingPushes::default();
|
||||
for &(id, v) in &pushes {
|
||||
all.push(id, v);
|
||||
}
|
||||
for split in [0, 30, 64, 70, 150, 200] {
|
||||
let (mut first, mut second) = (PendingPushes::default(), PendingPushes::default());
|
||||
for &(id, v) in &pushes[..split] {
|
||||
first.push(id, v);
|
||||
}
|
||||
for &(id, v) in &pushes[split..] {
|
||||
second.push(id, v);
|
||||
}
|
||||
first.append(second);
|
||||
assert_eq!(per_mob(&first), per_mob(&all), "split at {split}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1159,6 +1159,46 @@ impl<G: Goal + Clone + 'static> Goal for NotRetreating<G> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A goal of the fight activity: `HoglinAi.updateActivity` runs after the
|
||||
/// brain, so a hoglin given a target fights from the next tick on (here
|
||||
/// `brain_step` runs after the goals). The goal may start a tick early, as
|
||||
/// the goal selector picks new goals every other tick only and the brain
|
||||
/// every tick, but it acts only once the activity is fight.
|
||||
#[derive(Clone, Debug)]
|
||||
struct InFight<G>(G);
|
||||
impl<G: Goal + Clone + 'static> Goal for InFight<G> {
|
||||
goal_clone!();
|
||||
fn flags(&self) -> u8 {
|
||||
self.0.flags()
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
self.0.name()
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
self.0.can_use(m, cx)
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
self.0.can_continue(m, cx)
|
||||
}
|
||||
fn interruptable(&self) -> bool {
|
||||
self.0.interruptable()
|
||||
}
|
||||
fn every_tick(&self) -> bool {
|
||||
self.0.every_tick()
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.0.start(m, cx)
|
||||
}
|
||||
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.0.stop(m, cx)
|
||||
}
|
||||
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
if m.extra.piglin.activity == Activity::Fight {
|
||||
self.0.tick(m, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A goal of the idle and avoid activities' movement: the ride activity has
|
||||
/// none of it (`PiglinAi.initRideHoglinActivity`), so it waits while the
|
||||
/// brain is in the ride activity.
|
||||
@@ -1457,8 +1497,10 @@ pub fn register_hoglin(m: &mut Mob) {
|
||||
g.add(0, Float);
|
||||
// `HoglinAi.initRetreatActivity`: `SetWalkTargetAwayFrom.entity(AVOID_TARGET, 1.3, 15, false)`.
|
||||
g.add(1, WalkAway::retreat(1.3, 15.0, false));
|
||||
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies.
|
||||
g.add(2, MeleeAttack::new(1.0, true).intervals(40, 15));
|
||||
// HoglinAi: MeleeAttack.create(40) for adults, 15 for babies, in the
|
||||
// fight activity. Its `ATTACK_COOLING_DOWN` lives 40 brain ticks and
|
||||
// `MemorySlot.tick` erases it on the 41st, so hits come 41 (16) apart.
|
||||
g.add(2, InFight(MeleeAttack::new(1.0, true).intervals(41, 16)));
|
||||
g.add(3, NotRetreating(super::goals::Breed::new(1.0)));
|
||||
g.add(3, HoglinAvoidRepellent);
|
||||
// `HoglinAi.initIdleActivity`: an adult keeps away from the nearest adult
|
||||
|
||||
@@ -200,6 +200,7 @@ pub fn forgive_dead_player(m: &mut Mob) {
|
||||
/// `NeutralMob.stopBeingAngry`.
|
||||
fn stop_being_angry(m: &mut Mob) {
|
||||
m.ai.last_hurt_by = None;
|
||||
m.ai.last_hurt_by_ref = None;
|
||||
m.ai.target = None;
|
||||
super::neutral_anger::stop(m);
|
||||
m.extra.flag2 = false;
|
||||
|
||||
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user