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
@@ -1947,6 +1947,7 @@ name = "horizon-server-files"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
|
||||
+1
-1
@@ -530,7 +530,7 @@ unable to act, and never puts an account that is tied to a person into anyone el
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **GitHub organisation** (horizon-rust) | Two owners since 2026-11-11: the founder and rdbtCVS (P28); a `council` team holds CODEOWNERS (P35) and is the security managers (P36) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
|
||||
@@ -109,7 +109,7 @@ horizon-ctl break -1 65 -3 # mines it; replies with
|
||||
|
||||
## Build from source
|
||||
|
||||
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
Needs Rust 1.90+, Python 3.11+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
|
||||
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
|
||||
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -123,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`).
|
||||
@@ -1146,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.
|
||||
@@ -1166,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();
|
||||
@@ -1610,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);
|
||||
@@ -2124,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()
|
||||
@@ -2146,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.
|
||||
@@ -2223,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
@@ -3588,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);
|
||||
@@ -3990,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);
|
||||
}
|
||||
@@ -4078,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);
|
||||
@@ -5067,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.
@@ -48,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;
|
||||
@@ -3510,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,
|
||||
}
|
||||
@@ -3937,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -677,6 +677,7 @@ fn valid_player_target(w: &MobWorld) -> bool {
|
||||
fn stop_being_angry(m: &mut Mob) {
|
||||
// setLastHurtByMob(null) also stamps the time.
|
||||
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;
|
||||
m.extra.angry_at = None;
|
||||
|
||||
@@ -1,41 +1,67 @@
|
||||
//! The screen of a game started without its prepared files: the game build
|
||||
//! run by hand (the release's game zip) instead of through the launcher. It
|
||||
//! says where to get the launcher, and quits. Drawn in Horizon's design
|
||||
//! language with the bundled fonts: none of Minecraft's files are there.
|
||||
//! run by hand instead of through a launcher. It says where to get them, and
|
||||
//! quits. Drawn in Horizon's design language with the bundled fonts: none of
|
||||
//! Minecraft's files are there.
|
||||
//!
|
||||
//! The page it sends the player to comes from a hook (`horizon_hooks::Hooks::get_the_game`,
|
||||
//! Horizon's launcher in the official executable). With none, it says how to prepare the files
|
||||
//! and has only Quit.
|
||||
use crate::lang;
|
||||
use crate::ui_theme::{self, Face, Ui, color};
|
||||
use horizon_hooks::GetTheGame;
|
||||
|
||||
/// The launcher's download page.
|
||||
pub const DOWNLOAD_URL: &str = "https://playhorizon.dev";
|
||||
|
||||
const TITLE: &str = "Start Horizon from the launcher";
|
||||
const PARAGRAPHS: [&str; 3] = [
|
||||
"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 needs your own copy of Minecraft: Java Edition. The launcher signs you in and \
|
||||
prepares the game files from it.",
|
||||
"Download the launcher at playhorizon.dev, then press Play there.",
|
||||
];
|
||||
const COLUMN: f32 = 560.0;
|
||||
const BUTTON_GAP: f32 = 12.0;
|
||||
|
||||
/// What a click on the screen does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Open the launcher's download page.
|
||||
Download,
|
||||
/// Open the hook's page.
|
||||
Open(&'static str),
|
||||
Quit,
|
||||
}
|
||||
|
||||
pub struct NoResources {
|
||||
title: String,
|
||||
paragraphs: Vec<String>,
|
||||
/// The buttons' labels and actions, the hook's first.
|
||||
buttons: Vec<(String, Action)>,
|
||||
/// Where the prepared files were looked for, for whoever starts the game by hand on purpose.
|
||||
looked_in: String,
|
||||
}
|
||||
|
||||
impl NoResources {
|
||||
pub fn new(looked_in: &std::path::Path) -> Self {
|
||||
/// The screen in the current language, with `get`'s page when a hook answered
|
||||
/// (`horizon_hooks::engine::get_the_game`).
|
||||
pub fn new(looked_in: &std::path::Path, get: Option<GetTheGame>) -> Self {
|
||||
let shown = std::path::absolute(looked_in).unwrap_or_else(|_| looked_in.to_path_buf());
|
||||
let quit = (lang::get("horizon.no_resources.quit"), Action::Quit);
|
||||
let (title, paragraphs, buttons) = match get {
|
||||
Some(get) => {
|
||||
let site = get
|
||||
.address
|
||||
.split_once("://")
|
||||
.map_or(get.address, |(_, s)| s);
|
||||
(
|
||||
lang::get(get.title),
|
||||
get.paragraphs
|
||||
.iter()
|
||||
.map(|p| lang::tr(p, &[&site]))
|
||||
.collect(),
|
||||
vec![(lang::get(get.button), Action::Open(get.address)), quit],
|
||||
)
|
||||
}
|
||||
None => (
|
||||
lang::get("horizon.no_resources.title"),
|
||||
vec![lang::get("horizon.no_resources.prepare")],
|
||||
vec![quit],
|
||||
),
|
||||
};
|
||||
Self {
|
||||
looked_in: format!("No prepared game files in {}", shown.display()),
|
||||
title,
|
||||
paragraphs,
|
||||
buttons,
|
||||
looked_in: lang::tr("horizon.no_resources.looked_in", &[&shown.display()]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +70,8 @@ impl NoResources {
|
||||
fn layout(&self, ui: &Ui) -> Layout {
|
||||
let width = COLUMN.min(ui.w - 64.0).max(160.0);
|
||||
let x = ((ui.w - width) / 2.0).round();
|
||||
let lines: Vec<Vec<String>> = PARAGRAPHS
|
||||
let lines: Vec<Vec<String>> = self
|
||||
.paragraphs
|
||||
.iter()
|
||||
.map(|p| wrap(ui, Face::Regular, body(), p, width))
|
||||
.collect();
|
||||
@@ -55,14 +82,11 @@ impl NoResources {
|
||||
let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0;
|
||||
let mut buttons = Vec::new();
|
||||
let mut bx = x;
|
||||
for (label, action) in [
|
||||
("Download the launcher", Action::Download),
|
||||
("Quit", Action::Quit),
|
||||
] {
|
||||
for (label, action) in &self.buttons {
|
||||
let w = (ui.measure(Face::Medium, button_text(), label) + 40.0).round();
|
||||
buttons.push(Button {
|
||||
label,
|
||||
action,
|
||||
label: label.clone(),
|
||||
action: *action,
|
||||
x: bx,
|
||||
y: buttons_y,
|
||||
w,
|
||||
@@ -89,7 +113,7 @@ impl NoResources {
|
||||
layout.x,
|
||||
y,
|
||||
color::text(),
|
||||
TITLE,
|
||||
&self.title,
|
||||
);
|
||||
y += TITLE_LINE + 16.0;
|
||||
for paragraph in &layout.lines {
|
||||
@@ -110,8 +134,8 @@ impl NoResources {
|
||||
for button in &layout.buttons {
|
||||
let hovered = button.contains(ui.cursor);
|
||||
let (plate, ink) = match (button.action, hovered) {
|
||||
(Action::Download, false) => (color::accent(), color::on_accent()),
|
||||
(Action::Download, true) => {
|
||||
(Action::Open(_), false) => (color::accent(), color::on_accent()),
|
||||
(Action::Open(_), true) => {
|
||||
(color::with_alpha(color::accent(), 0.85), color::on_accent())
|
||||
}
|
||||
(Action::Quit, false) => (color::raised(), color::text()),
|
||||
@@ -119,7 +143,7 @@ impl NoResources {
|
||||
};
|
||||
let h = control();
|
||||
ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate);
|
||||
let lw = ui.measure(Face::Medium, button_text(), button.label);
|
||||
let lw = ui.measure(Face::Medium, button_text(), &button.label);
|
||||
let lx = button.x + ((button.w - lw) / 2.0).round();
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
@@ -128,7 +152,7 @@ impl NoResources {
|
||||
lx,
|
||||
button.y,
|
||||
ink,
|
||||
button.label,
|
||||
&button.label,
|
||||
);
|
||||
}
|
||||
let small_y = layout.buttons[0].y + control() + 20.0;
|
||||
@@ -191,7 +215,7 @@ struct Layout {
|
||||
}
|
||||
|
||||
struct Button {
|
||||
label: &'static str,
|
||||
label: String,
|
||||
action: Action,
|
||||
x: f32,
|
||||
y: f32,
|
||||
@@ -242,11 +266,68 @@ mod tests {
|
||||
f(&mut ui)
|
||||
}
|
||||
|
||||
/// A hook's answer, with the game's own Horizon strings and another page.
|
||||
const GET: GetTheGame = GetTheGame {
|
||||
address: "https://example.org/get",
|
||||
title: "horizon.get_the_game.title",
|
||||
paragraphs: &[
|
||||
"horizon.get_the_game.about",
|
||||
"horizon.get_the_game.minecraft",
|
||||
"horizon.get_the_game.download",
|
||||
],
|
||||
button: "horizon.get_the_game.button",
|
||||
};
|
||||
|
||||
/// The screen in English, every key it asks for there.
|
||||
fn screen(get: Option<GetTheGame>) -> NoResources {
|
||||
let (screen, missed) = lang::scoped(lang::Language::load(&[], "en_us"), || {
|
||||
NoResources::new(std::path::Path::new(".local/horizon-client/26.3"), get)
|
||||
});
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
screen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_hook_it_names_no_site_and_opens_no_page() {
|
||||
let screen = screen(None);
|
||||
let all = [&screen.title, &screen.looked_in]
|
||||
.into_iter()
|
||||
.chain(&screen.paragraphs)
|
||||
.chain(screen.buttons.iter().map(|(label, _)| label));
|
||||
for text in all {
|
||||
assert!(!text.to_lowercase().contains("playhorizon"), "{text}");
|
||||
assert!(!text.contains("launcher"), "{text}");
|
||||
}
|
||||
assert!(screen.paragraphs[0].contains("horizon-prepare --prepare"));
|
||||
assert_eq!(
|
||||
screen.buttons,
|
||||
[("Quit".to_owned(), Action::Quit)],
|
||||
"only Quit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hook_gives_the_text_and_the_page() {
|
||||
let screen = screen(Some(GET));
|
||||
assert_eq!(screen.title, "Start Horizon from the launcher");
|
||||
assert_eq!(
|
||||
screen.paragraphs[2],
|
||||
"Download the launcher at example.org/get, then press Play there."
|
||||
);
|
||||
assert_eq!(
|
||||
screen.buttons[0],
|
||||
(
|
||||
"Download the launcher".to_owned(),
|
||||
Action::Open("https://example.org/get")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_buttons_are_where_they_are_drawn() {
|
||||
let screen = NoResources::new(std::path::Path::new(".local/horizon-client/26.3"));
|
||||
let screen = screen(Some(GET));
|
||||
for size in [[1280, 720], [800, 600], [2560, 1440]] {
|
||||
let (download, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
|
||||
let (open, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
|
||||
let layout = screen.layout(ui);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + control() / 2.0);
|
||||
(
|
||||
@@ -257,8 +338,8 @@ mod tests {
|
||||
});
|
||||
let at = |p: (f32, f32)| (p.0 * density, p.1 * density);
|
||||
assert_eq!(
|
||||
with_ui(size, at(download), |ui| screen.click(ui)),
|
||||
Some(Action::Download),
|
||||
with_ui(size, at(open), |ui| screen.click(ui)),
|
||||
Some(Action::Open(GET.address)),
|
||||
"{size:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -280,14 +361,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn text_wraps_within_the_column_and_keeps_every_word() {
|
||||
let screen = screen(Some(GET));
|
||||
with_ui([1280, 720], (0.0, 0.0), |ui| {
|
||||
for paragraph in PARAGRAPHS {
|
||||
for paragraph in &screen.paragraphs {
|
||||
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
|
||||
assert!(lines.len() > 1, "{paragraph}");
|
||||
for line in &lines {
|
||||
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
|
||||
}
|
||||
assert_eq!(lines.join(" "), paragraph);
|
||||
assert_eq!(&lines.join(" "), paragraph);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +156,27 @@ fn visible(view: &SocialView, h: f32) -> impl Iterator<Item = (usize, f32)> + '_
|
||||
pub fn search_box(w: f32) -> [f32; 4] {
|
||||
[margin_x(w) + 28.0, 74.0, 200.0, 15.0]
|
||||
}
|
||||
/// `ContainerEventHandler.mouseClicked`: the screen's children in order, which
|
||||
/// the social screen inherits without an override of its own. A click inside
|
||||
/// the search box is that `EditBox`'s own `onClick` (the caret goes under the
|
||||
/// pointer); the tabs, the rows' buttons and Done come after it.
|
||||
pub fn search_click_at(size: (f32, f32), cursor: (f32, f32)) -> bool {
|
||||
let [x, y, w, h] = search_box(size.0);
|
||||
ui::hovered(cursor, x, y, w, h)
|
||||
}
|
||||
/// What the click at `cursor` belongs to: the search box first (the screen's
|
||||
/// child order), then the list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Click {
|
||||
Search,
|
||||
Action(Action),
|
||||
}
|
||||
pub fn click_kind(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Click> {
|
||||
if search_click_at(size, cursor) {
|
||||
return Some(Click::Search);
|
||||
}
|
||||
click(size, cursor, view).map(Click::Action)
|
||||
}
|
||||
/// Done's rectangle (`HeaderAndFooterLayout`'s footer).
|
||||
fn done(w: f32, h: f32) -> [f32; 4] {
|
||||
[(w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0]
|
||||
@@ -627,4 +648,31 @@ mod tests {
|
||||
assert!(max_scroll(240.0, 10) > 0.0);
|
||||
assert_eq!(max_scroll(1000.0, 2), 0.0);
|
||||
}
|
||||
|
||||
/// `SocialInteractionsScreen.mouseClicked` gives the widgets the click
|
||||
/// before the list: a click inside the search box is the box's own.
|
||||
#[test]
|
||||
fn a_click_in_the_search_box_is_the_boxes_own() {
|
||||
let size = (427.0, 240.0);
|
||||
let view = SocialView::default();
|
||||
let [x, y, w, h] = search_box(size.0);
|
||||
// Inside the box the click is the box's, at both corners.
|
||||
assert_eq!(
|
||||
click_kind(size, (x + 1.0, y + 1.0), &view),
|
||||
Some(Click::Search)
|
||||
);
|
||||
assert_eq!(
|
||||
click_kind(size, (x + w - 0.5, y + h - 0.5), &view),
|
||||
Some(Click::Search)
|
||||
);
|
||||
// Left of it and just below it the box has no claim.
|
||||
assert_eq!(click_kind(size, (x - 1.0, y + 1.0), &view), None);
|
||||
assert_eq!(click_kind(size, (x + 1.0, y + h), &view), None);
|
||||
// And the list keeps its own buttons: Done is not the box's click.
|
||||
let [dx, dy, dw, dh] = done(size.0, size.1);
|
||||
assert_eq!(
|
||||
click_kind(size, (dx + dw / 2.0, dy + dh / 2.0), &view),
|
||||
Some(Click::Action(Action::Done))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use horizon_world::{
|
||||
worldgen::generator::ProtoChunk,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
@@ -125,6 +126,30 @@ pub fn has_pending_map(item: &ItemStack) -> bool {
|
||||
type LoadedLoot = (u64, Option<Arc<LootData>>);
|
||||
static LOOT: Mutex<Option<LoadedLoot>> = Mutex::new(None);
|
||||
|
||||
thread_local! {
|
||||
/// Loot data this thread keeps to itself, read ahead of [`LOOT`]. A game
|
||||
/// never sets it: a test that installs a world pack's tables does, so the
|
||||
/// tables reach its own rolls only and never a test running beside it
|
||||
/// (each test runs on its own thread; #14266).
|
||||
static THREAD_LOOT: RefCell<Option<Arc<LootData>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn thread_loot() -> Option<Arc<LootData>> {
|
||||
THREAD_LOOT.with(|own| own.borrow().clone())
|
||||
}
|
||||
|
||||
/// Load the loot data of `root` with `overlay` for this thread alone:
|
||||
/// [`loot_data`] and [`loaded_loot_data`] return it on this thread, while
|
||||
/// other threads keep the process's copy. For tests whose world has packs.
|
||||
pub fn load_thread_loot_data(
|
||||
root: &Path,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
) -> Option<Arc<LootData>> {
|
||||
let data = load_loot(root, overlay).1;
|
||||
THREAD_LOOT.with(|own| *own.borrow_mut() = data.clone());
|
||||
data
|
||||
}
|
||||
|
||||
/// The resource root and the packs' files the loot data was loaded from.
|
||||
fn loot_key(root: &Path, overlay: &horizon_world::loot::PackOverlay) -> u64 {
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
@@ -149,6 +174,9 @@ fn load_loot(root: &Path, overlay: &horizon_world::loot::PackOverlay) -> LoadedL
|
||||
/// once per process (a world open with other packs, or `/reload`, loads it
|
||||
/// again).
|
||||
pub fn loot_data(root: &Path, overlay: &horizon_world::loot::PackOverlay) -> Option<Arc<LootData>> {
|
||||
if let Some(own) = thread_loot() {
|
||||
return Some(own);
|
||||
}
|
||||
let mut loot = LOOT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loot.get_or_insert_with(|| load_loot(root, overlay))
|
||||
.1
|
||||
@@ -183,6 +211,9 @@ fn reuse_or_load(
|
||||
|
||||
/// The loot data `loot_data` loaded, if it ran.
|
||||
pub fn loaded_loot_data() -> Option<Arc<LootData>> {
|
||||
if let Some(own) = thread_loot() {
|
||||
return Some(own);
|
||||
}
|
||||
LOOT.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
|
||||
@@ -186,10 +186,6 @@ impl Levels {
|
||||
fn get(&self, x: i32, z: i32) -> Option<(u8, u8)> {
|
||||
Some(self.0.as_ref()?.get(x, z))
|
||||
}
|
||||
/// Whether any ticket exists; with none, everything ticks.
|
||||
pub fn has_tickets(&self) -> bool {
|
||||
self.0.is_some()
|
||||
}
|
||||
/// `DistanceManager.inEntityTickingRange`, in an entity-ticking chunk:
|
||||
/// where entities tick.
|
||||
pub fn entity_ticking(&self, x: i32, z: i32) -> bool {
|
||||
@@ -256,41 +252,9 @@ pub struct ChunkTickets {
|
||||
sources: Option<Sources>,
|
||||
levels: Levels,
|
||||
portal: Vec<PortalTicket>,
|
||||
/// The resident chunk keys at one membership revision.
|
||||
resident: std::sync::Mutex<(u64, Arc<crate::fast_hash::FxHashSet<(i32, i32)>>)>,
|
||||
}
|
||||
|
||||
impl ChunkTickets {
|
||||
/// `Levels::entity_ticking_at`, held back as `ChunkMap.prepareEntityTickingChunk`
|
||||
/// does: a chunk waits until every chunk within 2 columns is loaded. With no
|
||||
/// tickets everything ticks.
|
||||
pub fn entity_ticking_area(
|
||||
&self,
|
||||
chunks: &crate::chunk_map::ChunkMap,
|
||||
gate: bool,
|
||||
) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
let levels = self.levels.clone();
|
||||
let resident = (gate && levels.0.is_some()).then(|| {
|
||||
let revision = chunks.membership_revision();
|
||||
let mut cache = self.resident.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.0 != revision {
|
||||
*cache = (revision, Arc::new(chunks.keys().copied().collect()));
|
||||
}
|
||||
cache.1.clone()
|
||||
});
|
||||
// Callers ask once per entity: remember each chunk's answer, so an
|
||||
// entity costs one lookup instead of the 25 of its neighbourhood.
|
||||
let surrounded = std::cell::RefCell::new(crate::fast_hash::FxHashMap::default());
|
||||
move |x, z| {
|
||||
levels.entity_ticking_at(x, z)
|
||||
&& resident.as_ref().is_none_or(|keys| {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
*surrounded.borrow_mut().entry((cx, cz)).or_insert_with(|| {
|
||||
(cz - 2..=cz + 2).all(|z| (cx - 2..=cx + 2).all(|x| keys.contains(&(x, z))))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
/// `None`: tick everywhere.
|
||||
pub fn update(&mut self, mut sources: Option<Sources>) {
|
||||
// The game submits a fresh `Sources` value every tick even when no
|
||||
|
||||
@@ -1523,7 +1523,6 @@ fn the_prediction_records_each_client_tick_and_the_server_ticks_it_had_seen() {
|
||||
inbox.push_back((S::ClientTickEnd(Empty), 0));
|
||||
s.tick(&[]);
|
||||
let record = s.hub.guests[0]
|
||||
.anticheat
|
||||
.record
|
||||
.as_deref()
|
||||
.expect("the prediction records");
|
||||
@@ -1547,7 +1546,7 @@ fn the_prediction_records_each_client_tick_and_the_server_ticks_it_had_seen() {
|
||||
0,
|
||||
));
|
||||
s.tick(&[]);
|
||||
let record = s.hub.guests[0].anticheat.record.as_deref().unwrap();
|
||||
let record = s.hub.guests[0].record.as_deref().unwrap();
|
||||
assert_eq!(record.seen(), Some(pinged));
|
||||
}
|
||||
|
||||
|
||||
@@ -152,9 +152,11 @@ fn predicate_fields(id: &str, map: &Compound, t: &Tag, reg: &Registries) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// ConfiguredFeature.DIRECT_CODEC: validates the feature `type` against
|
||||
/// `Feature.DIRECT_CODEC`: validates the feature `type` against
|
||||
/// `minecraft:worldgen/feature_type` and checks required configuration keys
|
||||
/// with DataFixerUpper error formatting.
|
||||
/// with DataFixerUpper error formatting. The type's fields sit beside `type`
|
||||
/// in the same map (there is no `config` wrapper), so a missing one names
|
||||
/// the whole map.
|
||||
fn check_feature(tag: &Tag, reg: &Registries) -> Result<(), String> {
|
||||
const KNOWN_FEATURE_TYPES: &[&str] = &[
|
||||
"minecraft:no_op",
|
||||
@@ -244,14 +246,8 @@ fn check_feature(tag: &Tag, reg: &Registries) -> Result<(), String> {
|
||||
"Unknown registry key in ResourceKey[minecraft:root / minecraft:worldgen/feature_type]: {full}"
|
||||
));
|
||||
}
|
||||
if full == "minecraft:simple_block" {
|
||||
let cfg_tag = get(c, "config").unwrap_or(tag);
|
||||
let Tag::Compound(cfg_map) = cfg_tag else {
|
||||
return Err(not_map(cfg_tag));
|
||||
};
|
||||
if get(cfg_map, "to_place").is_none() {
|
||||
return Err(no_key("to_place", cfg_tag));
|
||||
}
|
||||
if full == "minecraft:simple_block" && get(c, "to_place").is_none() {
|
||||
return Err(no_key("to_place", tag));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -271,9 +267,8 @@ fn dfu_feature_error(tag: &Tag, msg: &str) -> String {
|
||||
"Unknown registry key in ResourceKey[minecraft:root / minecraft:worldgen/feature_type]: {full}"
|
||||
);
|
||||
}
|
||||
let cfg_tag = tag.get("config").unwrap_or(tag);
|
||||
if matches!(cfg_tag, Tag::Compound(c) if get(c, field).is_none()) {
|
||||
return no_key(field, cfg_tag);
|
||||
if matches!(tag, Tag::Compound(c) if get(c, field).is_none()) {
|
||||
return no_key(field, tag);
|
||||
}
|
||||
}
|
||||
msg.to_owned()
|
||||
@@ -483,6 +478,43 @@ mod predicate_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod feature_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_missing_feature_field_names_the_whole_map() {
|
||||
// Feature.DIRECT_CODEC dispatches on `type` to the type's own map
|
||||
// codec; a `config` key is just another unread key.
|
||||
let reg = Registries::default();
|
||||
let decode_text = |text| {
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(text).unwrap();
|
||||
decode("minecraft:worldgen/feature", &tag, ®)
|
||||
};
|
||||
assert_eq!(
|
||||
decode_text(r#"{type:"minecraft:simple_block"}"#),
|
||||
Err(r#"No key to_place in MapLike[{type:"minecraft:simple_block"}]"#.into())
|
||||
);
|
||||
assert_eq!(
|
||||
decode_text(r#"{type:"minecraft:simple_block",config:{}}"#),
|
||||
Err(r#"No key to_place in MapLike[{config:{},type:"minecraft:simple_block"}]"#.into())
|
||||
);
|
||||
assert_eq!(
|
||||
decode_text(
|
||||
r#"{type:"minecraft:simple_block",config:{to_place:{type:"minecraft:simple_state_provider",state:{Name:"minecraft:stone"}}}}"#
|
||||
),
|
||||
Err(
|
||||
r#"No key to_place in MapLike[{config:{to_place:{state:{Name:"minecraft:stone"},type:"minecraft:simple_state_provider"}},type:"minecraft:simple_block"}]"#
|
||||
.into()
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
decode_text(r#"{type:"minecraft:simple_block",to_place:{id:"minecraft:stone"}}"#)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod contents_component_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -113,17 +113,34 @@ fn provider(id: &str) -> Option<Suggests> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest registry id kept, and how many distinct ones: a game knows a few hundred
|
||||
/// registries, with ids far shorter.
|
||||
const MAX_NAME: usize = 256;
|
||||
const MAX_NAMES: usize = 1024;
|
||||
|
||||
/// A registry name kept for the life of the game (argument types hold
|
||||
/// `&'static str`): each distinct name is stored once.
|
||||
/// `&'static str`): each distinct name is stored once. The names come from the server's command
|
||||
/// tree, so a server sending trees of new 32 KiB names packet after packet leaked memory until
|
||||
/// the client ran out: a name too long, not a resource location, or past [`MAX_NAMES`] is kept
|
||||
/// as "" instead (no suggestions for that argument), where vanilla's tree frees its keys anyway.
|
||||
fn intern(name: &str) -> &'static str {
|
||||
static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
|
||||
let mut names = NAMES
|
||||
.get_or_init(Default::default)
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
intern_in(&mut names, name)
|
||||
}
|
||||
fn intern_in(names: &mut HashSet<&'static str>, name: &str) -> &'static str {
|
||||
if let Some(&n) = names.get(name) {
|
||||
return n;
|
||||
}
|
||||
let resource_location = name
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'/' | b':'));
|
||||
if name.len() > MAX_NAME || !resource_location || names.len() >= MAX_NAMES {
|
||||
return "";
|
||||
}
|
||||
let n: &'static str = Box::leak(name.to_owned().into_boxed_str());
|
||||
names.insert(n);
|
||||
n
|
||||
@@ -280,6 +297,28 @@ pub fn argument_type(id: i32, props: &[u8]) -> ArgType {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_servers_registry_names_are_kept_within_bounds() {
|
||||
// A set of its own: the game's is shared with every other test.
|
||||
let mut names = HashSet::new();
|
||||
let item = intern_in(&mut names, "minecraft:item");
|
||||
assert_eq!(item, "minecraft:item");
|
||||
assert!(std::ptr::eq(item, intern_in(&mut names, "minecraft:item")));
|
||||
// Too long, or not a resource location: not kept.
|
||||
assert_eq!(intern_in(&mut names, &"a".repeat(32767)), "");
|
||||
assert_eq!(intern_in(&mut names, "Not A Registry"), "");
|
||||
// However many distinct names a server sends, the set stops growing.
|
||||
for i in 0..2 * MAX_NAMES {
|
||||
intern_in(&mut names, &format!("evil:registry_{i}"));
|
||||
}
|
||||
assert_eq!(names.len(), MAX_NAMES);
|
||||
assert_eq!(intern_in(&mut names, "evil:one_more_after_the_cap"), "");
|
||||
assert!(
|
||||
std::ptr::eq(item, intern_in(&mut names, "minecraft:item")),
|
||||
"kept names stay"
|
||||
);
|
||||
}
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::play::CommandNode,
|
||||
|
||||
@@ -251,12 +251,23 @@ pub fn accept(
|
||||
for l in &listeners {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
idle = false;
|
||||
let _ = s.set_nonblocking(false);
|
||||
if read_session(&mut s).ok() == Some(session)
|
||||
&& answer(&mut s, session).is_ok()
|
||||
{
|
||||
let _ = found.send(s);
|
||||
}
|
||||
// Each on its own thread, within one deadline: a stranger dripping a byte
|
||||
// at a time must not hold the only accept thread past the race.
|
||||
let found = found.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("horizon-friends-accept-one".into())
|
||||
.spawn(move || {
|
||||
let _ = s.set_nonblocking(false);
|
||||
let Ok(limit) = crate::deadline(&s, Duration::from_secs(2)) else {
|
||||
return;
|
||||
};
|
||||
if read_session(&mut s).ok() == Some(session)
|
||||
&& answer(&mut s, session).is_ok()
|
||||
{
|
||||
drop(limit);
|
||||
let _ = found.send(s);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if idle {
|
||||
|
||||
@@ -382,19 +382,20 @@ fn route_inbound(listeners: Vec<TcpListener>, shared: Arc<Shared>) {
|
||||
if shared.handshakes.load(Ordering::Relaxed) >= MAX_HANDSHAKES {
|
||||
continue;
|
||||
}
|
||||
shared.handshakes.fetch_add(1, Ordering::Relaxed);
|
||||
// Given back however the handshake ends, a failed spawn included: a slot lost each
|
||||
// time would close the direct port after MAX_HANDSHAKES of them.
|
||||
let slot = Handshake::take(&shared);
|
||||
let shared = shared.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("horizon-friends-inbound".into())
|
||||
.spawn(move || {
|
||||
let _ = s.set_nonblocking(false);
|
||||
let Ok(deadline) = crate::deadline(&s, HANDSHAKE) else {
|
||||
shared.handshakes.fetch_sub(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
match direct::read_hello(&mut s) {
|
||||
Ok(direct::Hello::Relayed(session)) => {
|
||||
shared.handshakes.fetch_sub(1, Ordering::Relaxed);
|
||||
drop(slot);
|
||||
let route = {
|
||||
let mut routes = shared.routes.lock().unwrap();
|
||||
routes.retain(|_, (_, until)| *until > Instant::now());
|
||||
@@ -407,11 +408,9 @@ fn route_inbound(listeners: Vec<TcpListener>, shared: Arc<Shared>) {
|
||||
}
|
||||
}
|
||||
Ok(direct::Hello::Code(session)) => {
|
||||
serve_code(&shared, s, session, deadline)
|
||||
}
|
||||
Err(_) => {
|
||||
shared.handshakes.fetch_sub(1, Ordering::Relaxed);
|
||||
serve_code(&shared, s, session, deadline, slot)
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -424,7 +423,27 @@ fn route_inbound(listeners: Vec<TcpListener>, shared: Arc<Shared>) {
|
||||
/// A guest who dialled an endpoint from the code: agree on the key from
|
||||
/// the code's secret, then carry the session (`deadline` bounds the
|
||||
/// handshake, not the game).
|
||||
fn serve_code(shared: &Arc<Shared>, mut s: TcpStream, session: u64, deadline: crate::Deadline) {
|
||||
/// One of the [`MAX_HANDSHAKES`], given back when dropped.
|
||||
struct Handshake(Arc<Shared>);
|
||||
impl Handshake {
|
||||
fn take(shared: &Arc<Shared>) -> Self {
|
||||
shared.handshakes.fetch_add(1, Ordering::Relaxed);
|
||||
Self(shared.clone())
|
||||
}
|
||||
}
|
||||
impl Drop for Handshake {
|
||||
fn drop(&mut self) {
|
||||
self.0.handshakes.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_code(
|
||||
shared: &Arc<Shared>,
|
||||
mut s: TcpStream,
|
||||
session: u64,
|
||||
deadline: crate::Deadline,
|
||||
slot: Handshake,
|
||||
) {
|
||||
let secret = shared
|
||||
.invite
|
||||
.lock()
|
||||
@@ -438,7 +457,7 @@ fn serve_code(shared: &Arc<Shared>, mut s: TcpStream, session: u64, deadline: cr
|
||||
session::secure(s, &key, Role::Host)
|
||||
})();
|
||||
drop(deadline);
|
||||
shared.handshakes.fetch_sub(1, Ordering::Relaxed);
|
||||
drop(slot);
|
||||
match secured {
|
||||
Ok(secure) => carry(shared, session, secure, true),
|
||||
Err(e) if crypto::is_wrong_code(&e) => {
|
||||
|
||||
@@ -247,6 +247,10 @@ impl Invite {
|
||||
} else {
|
||||
port
|
||||
};
|
||||
// As `encode` writes them: a crafted code can't make the guest dial thousands.
|
||||
if endpoints.len() == MAX_ENDPOINTS {
|
||||
return Err(CodeError::Version);
|
||||
}
|
||||
endpoints.push(SocketAddr::new(ip, p));
|
||||
}
|
||||
0x10 => {
|
||||
@@ -301,6 +305,25 @@ pub fn parse(text: &str) -> Option<Parsed> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_code_with_more_endpoints_than_encode_writes_is_refused() {
|
||||
// `bytes` writes at most MAX_ENDPOINTS: built by hand past it, with a valid checksum.
|
||||
let with = |n: usize| {
|
||||
let mut b = vec![VERSION];
|
||||
b.extend_from_slice(b"0123456789");
|
||||
b.extend_from_slice(&25580u16.to_be_bytes());
|
||||
for i in 0..n {
|
||||
b.push(0x04);
|
||||
b.extend_from_slice(&[10, 0, 0, i as u8]);
|
||||
}
|
||||
let sum = checksum(&b);
|
||||
b.extend_from_slice(&sum);
|
||||
Invite::decode(&b)
|
||||
};
|
||||
assert_eq!(with(MAX_ENDPOINTS).unwrap().endpoints.len(), MAX_ENDPOINTS);
|
||||
assert!(with(MAX_ENDPOINTS + 1).is_err());
|
||||
}
|
||||
|
||||
fn sample() -> Invite {
|
||||
Invite {
|
||||
secret: *b"0123456789",
|
||||
|
||||
@@ -599,6 +599,12 @@ impl Igd {
|
||||
let Some(control) = tag(block, "controlURL") else {
|
||||
continue;
|
||||
};
|
||||
// The device's text goes into a request line and a header: no line breaks, spaces or
|
||||
// other control characters in it.
|
||||
let clean = |s: &str| !s.chars().any(|c| c.is_control() || c == ' ');
|
||||
if !clean(control) || !clean(service) || !clean(&base) {
|
||||
continue;
|
||||
}
|
||||
let control = if control.starts_with("http://") {
|
||||
control.to_owned()
|
||||
} else {
|
||||
|
||||
@@ -35,6 +35,9 @@ enum Control {
|
||||
Path { direct: bool },
|
||||
}
|
||||
|
||||
/// Streams of the direct race a session authenticates, at most.
|
||||
const MAX_RACE_STREAMS: usize = 16;
|
||||
|
||||
fn send(w: &mut SecureWriter<TcpStream>, c: &Control) -> io::Result<()> {
|
||||
w.write_control(&serde_json::to_vec(c).map_err(io::Error::other)?)
|
||||
}
|
||||
@@ -114,12 +117,16 @@ pub(crate) fn settle(
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("horizon-friends-auth".into())
|
||||
.spawn(move || {
|
||||
for raw in raw_rx {
|
||||
// A few streams a session, each with one deadline for all of its handshake: whoever
|
||||
// knows a live session id could otherwise open stream after stream to the direct
|
||||
// port and drip bytes, a thread for each.
|
||||
for raw in raw_rx.into_iter().take(MAX_RACE_STREAMS) {
|
||||
let (key, atx) = (key.clone(), atx.clone());
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("horizon-friends-auth".into())
|
||||
.spawn(move || {
|
||||
let Ok(_) = raw.set_read_timeout(Some(Duration::from_secs(6))) else {
|
||||
let Ok(_deadline) = crate::deadline(&raw, Duration::from_secs(6))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut s) = secure(raw, &key, role) else {
|
||||
|
||||
@@ -107,7 +107,15 @@ pub fn query(server: &str, timeout: Duration) -> io::Result<SocketAddr> {
|
||||
let mut buf = [0u8; 576];
|
||||
for _ in 0..3 {
|
||||
socket.send_to(&request(&id), target)?;
|
||||
// One deadline an attempt: the read timeout alone restarts with every datagram, and a
|
||||
// socket kept fed would hold the query (and the host's setup waiting on it) for good.
|
||||
let until = std::time::Instant::now() + timeout / 3;
|
||||
loop {
|
||||
let left = until.saturating_duration_since(std::time::Instant::now());
|
||||
if left.is_zero() {
|
||||
break;
|
||||
}
|
||||
socket.set_read_timeout(Some(left))?;
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((n, from)) if from == target => {
|
||||
if let Some(a) = parse_response(&buf[..n], &id) {
|
||||
@@ -134,6 +142,29 @@ pub fn public_ip(servers: &[String], timeout: Duration) -> Option<IpAddr> {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A server that never answers the query but keeps the socket fed with other datagrams.
|
||||
#[test]
|
||||
fn a_query_kept_fed_with_noise_still_ends() {
|
||||
let silent = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let server = silent.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 576];
|
||||
if let Ok((_, from)) = silent.recv_from(&mut buf) {
|
||||
for _ in 0..200 {
|
||||
let _ = silent.send_to(b"noise", from);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
});
|
||||
let started = std::time::Instant::now();
|
||||
assert!(query(&server.to_string(), Duration::from_millis(300)).is_err());
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(2),
|
||||
"{:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// A STUN server answering with XOR-MAPPED-ADDRESS = `answer`.
|
||||
pub(crate) fn mock(answer: SocketAddr) -> SocketAddr {
|
||||
let s = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
|
||||
@@ -96,6 +96,27 @@ pub trait Hooks: Sync {
|
||||
/// What the player is doing, for a status shown outside the game (Discord's): called when it
|
||||
/// changes, not every frame. Splitscreen: the first player's only.
|
||||
fn activity(&self, _now: Activity) {}
|
||||
|
||||
/// Where a game started without its prepared files (run by hand, not from a launcher) sends
|
||||
/// the player for them: the screen's text and its button. The first hook that answers is
|
||||
/// used; with none, the screen says how to prepare the files and opens no page. Asked once,
|
||||
/// before that screen's window opens.
|
||||
fn get_the_game(&self) -> Option<GetTheGame> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::get_the_game`] answers. The texts are translation keys of the game's own strings
|
||||
/// (`assets/horizon/lang`); a `%s` in a paragraph is [`address`](Self::address) without its
|
||||
/// scheme.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct GetTheGame {
|
||||
/// The page the button opens.
|
||||
pub address: &'static str,
|
||||
pub title: &'static str,
|
||||
pub paragraphs: &'static [&'static str],
|
||||
/// The button's label.
|
||||
pub button: &'static str,
|
||||
}
|
||||
|
||||
/// What [`Hooks::client_started`] gets.
|
||||
@@ -335,6 +356,11 @@ pub mod engine {
|
||||
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
|
||||
all().iter().find_map(|hooks| hooks.tunnel())
|
||||
}
|
||||
|
||||
/// [`Hooks::get_the_game`]: the first registered hook's answer.
|
||||
pub fn get_the_game() -> Option<GetTheGame> {
|
||||
all().iter().find_map(|hooks| hooks.get_the_game())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -360,6 +386,14 @@ mod tests {
|
||||
fn activity(&self, now: Activity) {
|
||||
self.0.lock().unwrap().push(format!("{now:?}"));
|
||||
}
|
||||
fn get_the_game(&self) -> Option<GetTheGame> {
|
||||
Some(GetTheGame {
|
||||
address: "https://example.org",
|
||||
title: "recorder.title",
|
||||
paragraphs: &[],
|
||||
button: "recorder.button",
|
||||
})
|
||||
}
|
||||
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
self.0
|
||||
.lock()
|
||||
@@ -550,6 +584,10 @@ mod tests {
|
||||
}
|
||||
engine::shutdown();
|
||||
assert!(engine::any());
|
||||
assert_eq!(
|
||||
engine::get_the_game().map(|g| g.address),
|
||||
Some("https://example.org")
|
||||
);
|
||||
let mut atlas = Atlas::default();
|
||||
let model = |kind| players::PlayerModel::new(5, kind, Some(9), 0, false);
|
||||
// Before the frame put the sprite in: the decoration without its badge.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Horizon's own entries in the multiplayer list: the official server, added once at the top,
|
||||
//! and the withdrawn partner server, taken back out once.
|
||||
//! and the withdrawn partner server, taken back out once. And where the game started without its
|
||||
//! prepared files sends the player: Horizon's launcher, on its website ([`GET_THE_GAME`]).
|
||||
//!
|
||||
//! A Horizon crate (docs/layers.md): it plugs into the game through the platform's hooks
|
||||
//! ([`HOOKS`], when the game starts), which the official executable (`horizon-official`)
|
||||
@@ -7,7 +8,7 @@
|
||||
//! executable adds no server. Its addresses are in `horizon-addresses`.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use horizon_platform::hooks::{Hooks, Started};
|
||||
use horizon_platform::hooks::{GetTheGame, Hooks, Started};
|
||||
use horizon_platform::servers::{self, ServerEntry};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -21,8 +22,25 @@ impl Hooks for OfficialServers {
|
||||
fn started(&self, game: &Started<'_>) {
|
||||
update(game.game_dir);
|
||||
}
|
||||
|
||||
fn get_the_game(&self) -> Option<GetTheGame> {
|
||||
Some(GET_THE_GAME)
|
||||
}
|
||||
}
|
||||
|
||||
/// The game started by hand without its prepared files: start it from the launcher, downloaded
|
||||
/// from Horizon's website.
|
||||
pub const GET_THE_GAME: GetTheGame = GetTheGame {
|
||||
address: horizon_addresses::WEBSITE,
|
||||
title: "horizon.get_the_game.title",
|
||||
paragraphs: &[
|
||||
"horizon.get_the_game.about",
|
||||
"horizon.get_the_game.minecraft",
|
||||
"horizon.get_the_game.download",
|
||||
],
|
||||
button: "horizon.get_the_game.button",
|
||||
};
|
||||
|
||||
/// At the start of a player's game: the official server into the list in `game_dir` and the
|
||||
/// withdrawn partner server out of it, a failure only logged.
|
||||
pub fn update(game_dir: &Path) {
|
||||
|
||||
@@ -55,8 +55,8 @@ pub mod hooks {
|
||||
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
|
||||
};
|
||||
pub use horizon_hooks::{
|
||||
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
|
||||
tunnel,
|
||||
Activity, Client, Dimension, GameMode, GetTheGame, Hooks, Play, Started, TooLate, register,
|
||||
seat, tunnel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -113,8 +113,16 @@ pub fn each_list(kind: &str, v: &mut Value, f: &mut dyn FnMut(&mut Value) -> boo
|
||||
"processor_list" => f(v),
|
||||
"template_pool" => {
|
||||
let mut changed = false;
|
||||
for e in v["elements"].as_array_mut().into_iter().flatten() {
|
||||
let element = &mut e["element"];
|
||||
for e in v
|
||||
.get_mut("elements")
|
||||
.and_then(Value::as_array_mut)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
// Indexing would panic on an element that isn't an object.
|
||||
let Some(element) = e.get_mut("element") else {
|
||||
continue;
|
||||
};
|
||||
if let Some(list) = element.get_mut("elements").and_then(Value::as_array_mut) {
|
||||
for inner in list {
|
||||
if let Some(p) = inner.get_mut("processors") {
|
||||
|
||||
@@ -315,7 +315,10 @@ impl<'a> Context<'a> {
|
||||
.iter()
|
||||
.any(|k| o.get(*k).is_some_and(|x| self.depends_on_y(x, visiting)))
|
||||
}
|
||||
Some("spline") => self.spline_depends_on_y(&o["spline"], visiting),
|
||||
// A spline function without its spline is the pack's mistake: no Y from it.
|
||||
Some("spline") => o
|
||||
.get("spline")
|
||||
.is_some_and(|s| self.spline_depends_on_y(s, visiting)),
|
||||
_ => o
|
||||
.iter()
|
||||
.filter(|(k, _)| *k != "type")
|
||||
|
||||
@@ -432,6 +432,16 @@ mod tests {
|
||||
assert!(report.lossy.is_empty(), "{:?}", report.lossy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spline_without_its_spline_is_ported_not_a_panic() {
|
||||
// A cache over a spline function the pack left without its "spline" field.
|
||||
let (v, _) = up(
|
||||
"data/x/worldgen/density_function/f.json",
|
||||
json!({"type": "minecraft:flat_cache", "argument": {"type": "minecraft:spline"}}),
|
||||
);
|
||||
assert!(v.is_object(), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn density_arguments_and_removed_types() {
|
||||
let (v, _) = up(
|
||||
|
||||
@@ -401,8 +401,16 @@ fn upgrade_types(
|
||||
"template_pool" => {
|
||||
let mut changed = false;
|
||||
let mut elements: Vec<&mut Value> = Vec::new();
|
||||
for e in v["elements"].as_array_mut().into_iter().flatten() {
|
||||
let element = &mut e["element"];
|
||||
for e in v
|
||||
.get_mut("elements")
|
||||
.and_then(Value::as_array_mut)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
// Indexing would panic on an element that isn't an object.
|
||||
let Some(element) = e.get_mut("element") else {
|
||||
continue;
|
||||
};
|
||||
// A list element holds elements of its own.
|
||||
if element.get("elements").is_some_and(Value::is_array) {
|
||||
elements.extend(element["elements"].as_array_mut().into_iter().flatten());
|
||||
@@ -617,9 +625,13 @@ pub fn piece_counts<R: Read + std::io::Seek>(
|
||||
|
||||
/// Adds a structure's piece counts to its `horizon:piece_limited` key.
|
||||
pub fn add_piece_counts(id: &str, v: &mut Value, counts: &BTreeMap<String, Value>) -> bool {
|
||||
match (counts.get(id), v.get_mut("horizon:piece_limited")) {
|
||||
match (
|
||||
counts.get(id),
|
||||
v.get_mut("horizon:piece_limited")
|
||||
.and_then(Value::as_object_mut),
|
||||
) {
|
||||
(Some(pieces), Some(limited)) => {
|
||||
limited["pieces"] = pieces.clone();
|
||||
limited.insert("pieces".into(), pieces.clone());
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
@@ -1266,6 +1278,25 @@ mod tests {
|
||||
assert!(!add_piece_counts("rs:other", &mut nether, &counts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worldgen_files_of_an_unexpected_shape_are_left_alone() {
|
||||
let mut report = Upgrade::default();
|
||||
let none = BTreeMap::new();
|
||||
for mut v in [
|
||||
json!([1, 2]),
|
||||
json!({"elements": [1, {"weight": 1}]}),
|
||||
json!({"elements": 3}),
|
||||
json!({}),
|
||||
] {
|
||||
let before = v.clone();
|
||||
upgrade("template_pool", &mut v, &mut report, &none);
|
||||
assert_eq!(v, before);
|
||||
}
|
||||
let counts = BTreeMap::from([("rs:x".to_owned(), json!([{"name": "rs:a"}]))]);
|
||||
let mut v = json!({"horizon:piece_limited": 1});
|
||||
assert!(!add_piece_counts("rs:x", &mut v, &counts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_block_states_in_26_3_shape() {
|
||||
let mut report = Upgrade::default();
|
||||
|
||||
@@ -22,7 +22,9 @@ use crate::{
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
mod advancements;
|
||||
mod commands;
|
||||
pub use advancements::{Advancement, AdvancementDisplay, SeenAdvancements, UpdateAdvancements};
|
||||
pub use commands::{ARGUMENT_TYPES, ArgumentProperties, CommandNode, Commands};
|
||||
|
||||
// ---- shared structures -------------------------------------------------
|
||||
@@ -1337,172 +1339,6 @@ impl Codec<StopSound> for StopSound {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `DisplayInfo` of an advancement as sent.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AdvancementDisplay {
|
||||
pub title: Tag,
|
||||
pub description: Tag,
|
||||
pub icon: crate::item::Stack,
|
||||
/// `AdvancementType` ordinal: task, challenge, goal.
|
||||
pub frame: i32,
|
||||
pub show_toast: bool,
|
||||
pub hidden: bool,
|
||||
pub background: Option<String>,
|
||||
}
|
||||
/// `ClientboundUpdateAdvancementsPacket.PositionedAdvancement`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Advancement {
|
||||
pub id: String,
|
||||
pub parent: Option<String>,
|
||||
pub display: Option<AdvancementDisplay>,
|
||||
pub requirements: Vec<Vec<String>>,
|
||||
pub telemetry: bool,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
/// `ClientboundUpdateAdvancementsPacket`: whether it replaces what the
|
||||
/// client has, the advancements added and removed, the progress of each
|
||||
/// changed one (criterion, when obtained in epoch milliseconds), and whether
|
||||
/// to show toasts.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UpdateAdvancements {
|
||||
pub reset: bool,
|
||||
pub added: Vec<Advancement>,
|
||||
pub removed: Vec<String>,
|
||||
pub progress: Vec<(String, Vec<(String, Option<i64>)>)>,
|
||||
pub show: bool,
|
||||
}
|
||||
impl Codec<UpdateAdvancements> for UpdateAdvancements {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let reset = r.bool()?;
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut added = Vec::with_capacity(n.min(1024));
|
||||
for _ in 0..n {
|
||||
let id = r.string(32767)?;
|
||||
let parent = if r.bool()? {
|
||||
Some(r.string(32767)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let display = if r.bool()? {
|
||||
let title = r.nbt()?;
|
||||
let description = r.nbt()?;
|
||||
let icon = crate::item::Template::read(r)?;
|
||||
let frame = r.varint()?;
|
||||
let flags = r.i32()?;
|
||||
let background = if flags & 1 != 0 {
|
||||
Some(r.string(32767)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(AdvancementDisplay {
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
frame,
|
||||
show_toast: flags & 2 != 0,
|
||||
hidden: flags & 4 != 0,
|
||||
background,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let groups = r.count(r.remaining())?;
|
||||
let mut requirements = Vec::with_capacity(groups.min(256));
|
||||
for _ in 0..groups {
|
||||
let k = r.count(r.remaining())?;
|
||||
requirements.push((0..k).map(|_| r.string(32767)).collect::<Result<_>>()?);
|
||||
}
|
||||
let telemetry = r.bool()?;
|
||||
let (x, y) = (r.f32()?, r.f32()?);
|
||||
added.push(Advancement {
|
||||
id,
|
||||
parent,
|
||||
display,
|
||||
requirements,
|
||||
telemetry,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
let n = r.count(r.remaining())?;
|
||||
let removed = (0..n).map(|_| r.string(32767)).collect::<Result<_>>()?;
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut progress = Vec::with_capacity(n.min(1024));
|
||||
for _ in 0..n {
|
||||
let id = r.string(32767)?;
|
||||
let k = r.count(r.remaining())?;
|
||||
let mut criteria = Vec::with_capacity(k.min(256));
|
||||
for _ in 0..k {
|
||||
let name = r.string(32767)?;
|
||||
let at = if r.bool()? { Some(r.i64()?) } else { None };
|
||||
criteria.push((name, at));
|
||||
}
|
||||
progress.push((id, criteria));
|
||||
}
|
||||
let show = r.bool()?;
|
||||
Ok(Self {
|
||||
reset,
|
||||
added,
|
||||
removed,
|
||||
progress,
|
||||
show,
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
w.bool(v.reset);
|
||||
w.varint(v.added.len() as i32);
|
||||
for a in &v.added {
|
||||
w.string(&a.id);
|
||||
w.bool(a.parent.is_some());
|
||||
if let Some(p) = &a.parent {
|
||||
w.string(p);
|
||||
}
|
||||
w.bool(a.display.is_some());
|
||||
if let Some(d) = &a.display {
|
||||
w.nbt(&d.title);
|
||||
w.nbt(&d.description);
|
||||
crate::item::Template::write(&d.icon, w);
|
||||
w.varint(d.frame);
|
||||
w.i32(
|
||||
i32::from(d.background.is_some())
|
||||
| i32::from(d.show_toast) << 1
|
||||
| i32::from(d.hidden) << 2,
|
||||
);
|
||||
if let Some(b) = &d.background {
|
||||
w.string(b);
|
||||
}
|
||||
}
|
||||
w.varint(a.requirements.len() as i32);
|
||||
for g in &a.requirements {
|
||||
w.varint(g.len() as i32);
|
||||
for c in g {
|
||||
w.string(c);
|
||||
}
|
||||
}
|
||||
w.bool(a.telemetry);
|
||||
w.f32(a.x);
|
||||
w.f32(a.y);
|
||||
}
|
||||
w.varint(v.removed.len() as i32);
|
||||
for id in &v.removed {
|
||||
w.string(id);
|
||||
}
|
||||
w.varint(v.progress.len() as i32);
|
||||
for (id, criteria) in &v.progress {
|
||||
w.string(id);
|
||||
w.varint(criteria.len() as i32);
|
||||
for (name, at) in criteria {
|
||||
w.string(name);
|
||||
w.bool(at.is_some());
|
||||
if let Some(at) = at {
|
||||
w.i64(*at);
|
||||
}
|
||||
}
|
||||
}
|
||||
w.bool(v.show);
|
||||
}
|
||||
}
|
||||
packet!(
|
||||
/// ClientboundAwardStatsPacket: (stat type, value, count) by network id.
|
||||
AwardStats { stats: Vec<(i32, i32, i32)> => StatList }
|
||||
@@ -2009,30 +1845,6 @@ packet!(
|
||||
carried: Option<Hashed> => HashedSlot,
|
||||
}
|
||||
);
|
||||
/// ServerboundSeenAdvancementsPacket: the tab the player opened (action 0, `OPENED_TAB`), or
|
||||
/// that its screen closed (1, `CLOSED_SCREEN`, which carries no tab).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SeenAdvancements {
|
||||
pub action: i32,
|
||||
pub tab: Option<String>,
|
||||
}
|
||||
impl Codec<SeenAdvancements> for SeenAdvancements {
|
||||
fn read(r: &mut Reader) -> Result<SeenAdvancements> {
|
||||
let action = r.varint()?;
|
||||
// The identifier is there for OPENED_TAB only (`readIdentifier` in the other branch).
|
||||
let tab = match action {
|
||||
0 => Some(r.string(32767)?),
|
||||
_ => None,
|
||||
};
|
||||
Ok(SeenAdvancements { action, tab })
|
||||
}
|
||||
fn write(v: &SeenAdvancements, w: &mut Writer) {
|
||||
w.varint(v.action);
|
||||
if v.action == 0 {
|
||||
w.string(v.tab.as_deref().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
packet!(SelectTrade { item: i32 => VarInt });
|
||||
packet!(
|
||||
/// ServerboundBlockEntityTagQueryPacket: an operator's F3+I asks for the
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! The advancement packets: `ClientboundUpdateAdvancementsPacket` and
|
||||
//! `ServerboundSeenAdvancementsPacket`.
|
||||
use crate::{
|
||||
Result,
|
||||
buf::{Codec, Reader, Writer},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `DisplayInfo` of an advancement as sent.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AdvancementDisplay {
|
||||
pub title: Tag,
|
||||
pub description: Tag,
|
||||
pub icon: crate::item::Stack,
|
||||
/// `AdvancementType` ordinal: task, challenge, goal.
|
||||
pub frame: i32,
|
||||
pub show_toast: bool,
|
||||
pub hidden: bool,
|
||||
pub background: Option<String>,
|
||||
}
|
||||
/// `ClientboundUpdateAdvancementsPacket.PositionedAdvancement`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Advancement {
|
||||
pub id: String,
|
||||
pub parent: Option<String>,
|
||||
pub display: Option<AdvancementDisplay>,
|
||||
pub requirements: Vec<Vec<String>>,
|
||||
pub telemetry: bool,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
/// `ClientboundUpdateAdvancementsPacket`: whether it replaces what the
|
||||
/// client has, the advancements added and removed, the progress of each
|
||||
/// changed one (criterion, when obtained in epoch milliseconds), and whether
|
||||
/// to show toasts.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UpdateAdvancements {
|
||||
pub reset: bool,
|
||||
pub added: Vec<Advancement>,
|
||||
pub removed: Vec<String>,
|
||||
pub progress: Vec<(String, Vec<(String, Option<i64>)>)>,
|
||||
pub show: bool,
|
||||
}
|
||||
impl Codec<UpdateAdvancements> for UpdateAdvancements {
|
||||
fn read(r: &mut Reader) -> Result<Self> {
|
||||
let reset = r.bool()?;
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut added = Vec::with_capacity(n.min(1024));
|
||||
for _ in 0..n {
|
||||
let id = r.string(32767)?;
|
||||
let parent = if r.bool()? {
|
||||
Some(r.string(32767)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let display = if r.bool()? {
|
||||
let title = r.nbt()?;
|
||||
let description = r.nbt()?;
|
||||
let icon = crate::item::Template::read(r)?;
|
||||
let frame = r.varint()?;
|
||||
let flags = r.i32()?;
|
||||
let background = if flags & 1 != 0 {
|
||||
Some(r.string(32767)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(AdvancementDisplay {
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
frame,
|
||||
show_toast: flags & 2 != 0,
|
||||
hidden: flags & 4 != 0,
|
||||
background,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let groups = r.count(r.remaining())?;
|
||||
let mut requirements = Vec::with_capacity(groups.min(256));
|
||||
for _ in 0..groups {
|
||||
let k = r.count(r.remaining())?;
|
||||
requirements.push((0..k).map(|_| r.string(32767)).collect::<Result<_>>()?);
|
||||
}
|
||||
let telemetry = r.bool()?;
|
||||
let (x, y) = (r.f32()?, r.f32()?);
|
||||
added.push(Advancement {
|
||||
id,
|
||||
parent,
|
||||
display,
|
||||
requirements,
|
||||
telemetry,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
let n = r.count(r.remaining())?;
|
||||
let removed = (0..n).map(|_| r.string(32767)).collect::<Result<_>>()?;
|
||||
let n = r.count(r.remaining())?;
|
||||
let mut progress = Vec::with_capacity(n.min(1024));
|
||||
for _ in 0..n {
|
||||
let id = r.string(32767)?;
|
||||
let k = r.count(r.remaining())?;
|
||||
let mut criteria = Vec::with_capacity(k.min(256));
|
||||
for _ in 0..k {
|
||||
let name = r.string(32767)?;
|
||||
let at = if r.bool()? { Some(r.i64()?) } else { None };
|
||||
criteria.push((name, at));
|
||||
}
|
||||
progress.push((id, criteria));
|
||||
}
|
||||
let show = r.bool()?;
|
||||
Ok(Self {
|
||||
reset,
|
||||
added,
|
||||
removed,
|
||||
progress,
|
||||
show,
|
||||
})
|
||||
}
|
||||
fn write(v: &Self, w: &mut Writer) {
|
||||
w.bool(v.reset);
|
||||
w.varint(v.added.len() as i32);
|
||||
for a in &v.added {
|
||||
w.string(&a.id);
|
||||
w.bool(a.parent.is_some());
|
||||
if let Some(p) = &a.parent {
|
||||
w.string(p);
|
||||
}
|
||||
w.bool(a.display.is_some());
|
||||
if let Some(d) = &a.display {
|
||||
w.nbt(&d.title);
|
||||
w.nbt(&d.description);
|
||||
crate::item::Template::write(&d.icon, w);
|
||||
w.varint(d.frame);
|
||||
w.i32(
|
||||
i32::from(d.background.is_some())
|
||||
| i32::from(d.show_toast) << 1
|
||||
| i32::from(d.hidden) << 2,
|
||||
);
|
||||
if let Some(b) = &d.background {
|
||||
w.string(b);
|
||||
}
|
||||
}
|
||||
w.varint(a.requirements.len() as i32);
|
||||
for g in &a.requirements {
|
||||
w.varint(g.len() as i32);
|
||||
for c in g {
|
||||
w.string(c);
|
||||
}
|
||||
}
|
||||
w.bool(a.telemetry);
|
||||
w.f32(a.x);
|
||||
w.f32(a.y);
|
||||
}
|
||||
w.varint(v.removed.len() as i32);
|
||||
for id in &v.removed {
|
||||
w.string(id);
|
||||
}
|
||||
w.varint(v.progress.len() as i32);
|
||||
for (id, criteria) in &v.progress {
|
||||
w.string(id);
|
||||
w.varint(criteria.len() as i32);
|
||||
for (name, at) in criteria {
|
||||
w.string(name);
|
||||
w.bool(at.is_some());
|
||||
if let Some(at) = at {
|
||||
w.i64(*at);
|
||||
}
|
||||
}
|
||||
}
|
||||
w.bool(v.show);
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerboundSeenAdvancementsPacket: the tab the player opened (action 0, `OPENED_TAB`), or
|
||||
/// that its screen closed (1, `CLOSED_SCREEN`, which carries no tab).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SeenAdvancements {
|
||||
pub action: i32,
|
||||
pub tab: Option<String>,
|
||||
}
|
||||
impl Codec<SeenAdvancements> for SeenAdvancements {
|
||||
fn read(r: &mut Reader) -> Result<SeenAdvancements> {
|
||||
let action = r.varint()?;
|
||||
// The identifier is there for OPENED_TAB only (`readIdentifier` in the other branch).
|
||||
let tab = match action {
|
||||
0 => Some(r.string(32767)?),
|
||||
_ => None,
|
||||
};
|
||||
Ok(SeenAdvancements { action, tab })
|
||||
}
|
||||
fn write(v: &SeenAdvancements, w: &mut Writer) {
|
||||
w.varint(v.action);
|
||||
if v.action == 0 {
|
||||
w.string(v.tab.as_deref().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user