Compare commits

..
Author SHA1 Message Date
Ludovic Marie 9e6cd2ed70 Merge pull request #2803 from ludovic111/test/server-check-text-busy
🤖 Agent: verify passed; test-only fix for the horizon-server ETXTBSY flake that failed #2215. Queued by crate-split-1.
2026-10-27 14:28:25 +01:00
Ludovic Marie a813c4a755 Merge pull request #2669 from kacpherek/fix/toast-stack-f1
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:26:19 +01:00
Ludovic Marie e8e7254710 Merge pull request #2668 from kacpherek/fix/chat-history-restricted
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:26:14 +01:00
Ludovic Marie 639100ac95 Merge pull request #2667 from kacpherek/fix/passenger-tracking-range
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:26:09 +01:00
Ludovic Marie 9cb12d984b Merge pull request #2665 from kacpherek/fix/mount-hud
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:26:05 +01:00
Ludovic Marie b211519e79 Merge pull request #2660 from kacpherek/fix/sleep-messages
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:25:58 +01:00
Ludovic Marie 23d7e03a5b Merge pull request #2540 from kacpherek/fix/sync-mob-variants
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in cg19's union R1/R1b (union/cg19-r1b 6ec974ab3); main merged in since, CI green at this head.
2026-10-27 14:21:20 +01:00
Ludovic Marie 9a8e0e36a9 Merge pull request #2401 from kacpherek/fix/armor-stand-pose
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in cg19's union R1/R1b (union/cg19-r1b 6ec974ab3); main merged in since, CI green at this head.
2026-10-27 14:21:12 +01:00
Ludovic Marie d68895d0b1 Merge pull request #2340 from kacpherek/fix/cave-spider-husk-difficulty
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in cg19's union R1/R1b (union/cg19-r1b 6ec974ab3); main merged in since, CI green at this head.
2026-10-27 14:21:07 +01:00
Ludovic Marie 59ed6869e3 Merge pull request #2801 from ludovic111/feat/nightly-game-label
🤖 Agent: verified with pr_ready --quick (fmt, clippy, 1443 tests green); nightly label screenshot in the body. Queued at 2af3308.
2026-10-27 14:19:33 +01:00
Ludovic Marie 1ac4bcbe9b Merge pull request #2690 from kacpherek/fix/mob-ambient-particles
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:19:27 +01:00
Ludovic Marie 9b487bdfe9 Merge pull request #2687 from kacpherek/feat/shelf-use
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:19:23 +01:00
Ludovic Marie 1ec2b888aa Merge pull request #2680 from kacpherek/fix/spider-jockey-hoglin-toss
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:19:19 +01:00
Ludovic Marie dbb396e599 Merge pull request #2678 from kacpherek/fix/skeleton-freezes-to-stray
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:19:12 +01:00
Ludovic Marie 1e3a9d53a7 Merge pull request #2386 from kacpherek/fix/mace-smash-knockback-players
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:10:14 +01:00
Ludovic Marie e5cd63b608 Merge pull request #2337 from kacpherek/fix/shears-wear
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:10:04 +01:00
Ludovic Marie 06522c11db Merge pull request #2334 from kacpherek/fix/cat-spawner
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:09:56 +01:00
Ludovic MarieandClaude Opus 5.5 be0a93271f test: retry ETXTBSY when horizon-server tests run a script they just wrote
engine::tests::the_check_runs_the_program failed on CI (verify of #2215)
with "can't start: Text file busy (os error 26)". The test writes a shell
script and runs it at once; when another test thread forks to spawn a
process while the script's write handle is open, the child keeps that
handle until it execs, and running the script meanwhile fails with
ETXTBSY on Linux. The two engine tests that do this now retry that one
error (up to 50 times, 20 ms apart). Test-only change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:04:41 +01:00
Ludovic Marie 7bdfe942a4 Merge pull request #2779 from ludovic111/feat/nightly-channel
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after reading the workflows: the union build runs without secrets, a separate job only hashes and signs with a channel-tagged message (release launchers refuse nightly signatures, #2792), and the workflows always run from main. The release dry run on this head (40504185084) is green on every job.
2026-10-27 14:02:39 +01:00
Ludovic MarieandClaude Opus 5.5 0ea243a1c7 Merge main into fix/sync-mob-variants
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:01:44 +01:00
Ludovic MarieandClaude Opus 5.5 9dd27667f2 Merge main into fix/armor-stand-pose
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:01:35 +01:00
Ludovic MarieandClaude Opus 5.5 8709026be3 Merge main into fix/cave-spider-husk-difficulty
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:01:25 +01:00
Ludovic MarieandClaude Opus 5.5 2af3308892 Nightly builds name themselves on the title screen and in reports
A game built by the nightly workflow (HORIZON_NIGHTLY="<date> <sha>") draws
"Nightly <date> · <sha>" in the title screen's top-left corner, reports it as
its version in crash reports, and logs it as the log's first line, so a bug
report that carries the log names the build. Telemetry gets the version the
launcher installs it as (nightly-<date>-<sha>). Releases and source builds
are unchanged; only the "Horizon <version>" log line is new for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:01:12 +01:00
Ludovic Marie 2b910fa16d Merge pull request #2793 from ludovic111/test/win-screen-temp-dirs
🤖 Agent: test-only fix for the win_screen flake (two parallel tests shared one temp dir; each call now gets its own). pr_ready --quick green (1329 tests). Queued once pr_ready was green, as the orchestrator asked; merge after verify passes.
2026-10-27 14:00:30 +01:00
Ludovic Marie 4483c2613c Merge pull request #2792 from ludovic111/feat/nightly-launcher
🤖 Agent: merging the opt-in Nightly setting (off by default, own instance); fmt, clippy -D warnings, launcher lib tests and horizon-release-sign tests pass, including the release channel refusing nightly signatures.
2026-10-27 13:55:38 +01:00
Ludovic Marie 3b19c386cf Merge pull request #2354 from kacpherek/fix/dragon-kill-credit
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:46:08 +01:00
Ludovic Marie b4b80eaeb2 Merge pull request #2361 from kacpherek/fix/immediate-respawn
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:40:48 +01:00
Ludovic Marie 71d0220215 Merge pull request #2332 from kacpherek/fix/stations-unbreakable
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:40:16 +01:00
Ludovic Marie c27e3287c5 Merge pull request #2331 from kacpherek/fix/mob-custom-persistence
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:40:13 +01:00
Ludovic Marie c326afc7f1 Merge pull request #2327 from kacpherek/fix/thorns-unbreaking
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:40:09 +01:00
Ludovic Marie fd6c1e1770 Merge pull request #2323 from kacpherek/fix/warden-disables-shield
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:40:03 +01:00
Ludovic Marie 82d36d1f9b Merge pull request #2322 from kacpherek/fix/impaling-aquatic-tag
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:39:55 +01:00
Ludovic MarieandClaude Opus 5.5 665c28588e Merge main into fix/sync-mob-variants
main's armadillo scute shedding (#2337) and this branch's armadillo brain both
hooked Armadillo.customServerAiStep: one arm now runs the brain, then the
scute, as vanilla does. creatures.rs keeps PandaSneezed and main's ShedScute.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:39:48 +01:00
Ludovic Marie a64c4c6562 Merge pull request #2476 from kacpherek/fix/chat-links
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union A3 0c0206714 (main d9b6483aa + 2503 2521 2513 2555 2476) Mac mini 95/.
2026-10-27 13:39:22 +01:00
Ludovic MarieandClaude Opus 5.5 588b2fd116 Merge main into fix/armor-stand-pose
armor_stands.rs: main's armour glint overlay, drawn with this branch's
posed armour layers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:37:12 +01:00
Ludovic MarieandClaude Opus 5.5 7a0610ade8 Merge main into fix/cave-spider-husk-difficulty
mobs.rs MobWorld: main's mob_drops field beside the per-mob difficulty doc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:36:15 +01:00
Ludovic Marie b3fff5222b Merge pull request #2495 from kacpherek/fix/pet-owner-checks
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union A 5fb64f0d6 (main c1ebf5d1b + 50 PRs) Mac mini 352/354 harnesses (fin.
2026-10-27 13:34:15 +01:00
Ludovic Marie 9281a0b5d2 Merge pull request #2491 from kacpherek/fix/1780-exit-portal-rebuild
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union A 5fb64f0d6 (main c1ebf5d1b + 50 PRs) Mac mini 352/354 harnesses (fin.
2026-10-27 13:34:07 +01:00
Ludovic Marie 38588ab7aa Merge pull request #2490 from kacpherek/fix/2485-redstone-ore-step
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union A 5fb64f0d6 (main c1ebf5d1b + 50 PRs) Mac mini 352/354 harnesses (fin.
2026-10-27 13:34:01 +01:00
Ludovic Marie 8521aaac41 Merge pull request #2400 from kacpherek/fix/cat-sit-block
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:33:21 +01:00
Ludovic Marie bdd6b0ea12 Merge pull request #2396 from kacpherek/fix/spectator-crosshair
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:33:16 +01:00
Ludovic Marie c179c8753f Merge pull request #2377 from kacpherek/fix/raider-banner-reachable
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:33:12 +01:00
Ludovic Marie 19abf6182d Merge pull request #2330 from kacpherek/fix/mob-farmland-trample
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:33:08 +01:00
Ludovic Marie f87bbb5342 Merge pull request #2329 from kacpherek/fix/punch-knockback-resistance
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:32:19 +01:00
Ludovic Marie 7c7937438e Merge pull request #2321 from kacpherek/fix/bane-slowness
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:32:15 +01:00
Ludovic Marie 6207624448 Merge pull request #2311 from kacpherek/fix/1661-shulker-rotation
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:32:12 +01:00
Ludovic Marie 4f7a0f2ba1 Merge pull request #2781 from ludovic111/refactor/cut-options-opt
🤖 Agent: move only: the Opt enum (option ids) goes from options_ui.rs to settings.rs (re-exported from options_ui), cutting graphics_ui -> options_ui (step B of the cycle plan). pr_ready --quick green (1323 tests). Queued once pr_ready was green, as the orchestrator asked; merge after verify and parity pass.
2026-10-27 13:32:08 +01:00
Ludovic MarieandClaude Opus 5.5 24800ac54e ci: hand-started release dry runs get their own concurrency group
A dry run publishes nothing, so it can't race a real release; in the shared game-release group a release queued
behind it replaced it as the pending run and GitHub cancelled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:29:22 +01:00
Ludovic MarieandClaude Opus 5.5 66dabf51ee launcher: an opt-in Nightly setting that plays in its own instance
Settings > Updates gets "Nightly builds", off by default: "Reviewed fixes not released yet; may break." On, the
launcher installs the `nightly` pre-release as its own version (nightly-<date>-<commit>), in an instance of its
own ("Nightly"), and plays it; every update check (30 min) installs a newer nightly and drops the older one. The
Horizon instance keeps the release and the player's worlds: choosing it in the sidebar plays the release again,
and turning Nightly off does that too.

Nightly checksums must be signed for the nightly channel ("horizon release nightly"): horizon-release-sign's
signed_digest refuses nightly tags outright, and a nightly's signature never verifies under a release tag nor a
release's under the nightly channel (tests in both crates).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:28:17 +01:00
Ludovic Marie 88aa505533 Merge pull request #2778 from ludovic111/refactor/cut-health-rules
🤖 Agent: move only: HealthRules (and its Default) go from health.rs to world.rs (re-exported from health), cutting world -> health (step B of the cycle plan). pr_ready --quick green (1323 tests). Queued once pr_ready was green, as the orchestrator asked; merge after verify and parity pass.
2026-10-27 13:26:11 +01:00
Ludovic Marie ab080a5407 Merge pull request #2776 from ludovic111/refactor/cut-effect-tables
🤖 Agent: move only: the MobEffects/Potions tables go from brewing.rs to horizon-client-core::potions (re-exported from brewing), cutting effects -> brewing and hud_overlays -> brewing (step B of the cycle plan). pr_ready --quick green (1361 tests). Queued at the orchestrator's request once pr_ready was green; merge after verify and parity pass.
2026-10-27 13:21:06 +01:00
Ludovic MarieandClaude Opus 5.5 d89e27113f ci: an opt-in Nightly build from the proven union branches
nightly-build.yml builds, once a night, the newest union/<agent>-<name> branch on origin when it is newer than
main and holds something main lacks (else main), and publishes it as the `nightly` pre-release; the previous one
stays a week as nightly-<date> (tools/ci/nightly.py pick/rotate/prune). Launchers ignore it until a player turns
Nightly on (next PR).

The release builds move unchanged into game-build.yml, a reusable workflow both release.yml and the nightly call
from main's copy. The nightly builds with no secrets and the macOS keychain locked; only the sign job holds the
key, and it runs nothing but main's sign_release.sh over the archives, for the message "horizon release nightly".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:14:32 +01:00
Ludovic MarieandClaude Opus 5.5 f08a564fa6 test: give each win_screen test its own temp dir
The helper named its resource root after the pid, the poem flag and the
end text's length, so two tests with the same arguments, running in
parallel, shared it and one removed the other's files (os error 22 in
the_screen_ends_once_everything_rolled_past). A counter makes each call's
directory unique.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:08:52 +01:00
Ludovic Marie 7262304a6d Merge pull request #2773 from ludovic111/feat/sculk-sidebar-card
🤖 Agent: merging the Sculk sidebar card (Ludovic approved the screenshots); fmt, clippy -D warnings and launcher tests pass on the head merged with main.
2026-10-27 13:04:06 +01:00
Ludovic Marie 1d2c5db426 Merge pull request #2769 from ludovic111/refactor/cut-villager-poi
🤖 Agent: verify passed at 84c631a88. Move only: the villager Poi enum goes from mobs/villager_brain.rs to poi_sections.rs (re-exported), cutting world -> mobs (step B of the cycle plan). pr_ready --quick green (1323 tests). Ready to merge once parity is green.
2026-10-27 12:54:54 +01:00
Ludovic MarieandClaude Opus 5.5 964fbe1c29 Merge origin/main into feat/sculk-sidebar-card
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:52:56 +01:00
Ludovic Marie 6ab1b40ad4 Merge pull request #2252 from kacpherek/fix/wither-rose-kills
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21); main merged in since, CI green at this head.
2026-10-27 12:46:09 +01:00
Ludovic Marie cd18488cc2 Merge pull request #2258 from kacpherek/fix/rain-sound-vanilla
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21); main merged in since, CI green at this head.
2026-10-27 12:46:06 +01:00
Ludovic Marie 970ee43f8e Merge pull request #2259 from kacpherek/fix/levitation-and-spyglass-triggers
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21); main merged in since, CI green at this head.
2026-10-27 12:46:02 +01:00
Ludovic Marie e05847289f Merge pull request #2766 from ludovic111/refactor/cut-saved-proto
🤖 Agent: verify passed at 83bdb31ee. Move only: SavedProto goes from save.rs to chunk.rs (re-exported from save), cutting streaming -> save (step B of the cycle plan). pr_ready --quick green (1323 tests). Ready to merge once parity is green.
2026-10-27 12:45:22 +01:00
Ludovic MarieandClaude Opus 5.5 bc0c0fdf4a refactor: the MobEffects and Potions tables live in horizon-client-core
effects.rs and hud_overlays.rs read the effect and potion tables from
brewing, which made them depend on brewing (and through it on containers
and items), edges that keep the client lib in a single cycle. The tables
have no dependencies, so they move to horizon-client-core; brewing
re-exports them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:43:11 +01:00
Ludovic Marie 97437beaff Merge pull request #2296 from masa-masa-0314/fix/enchantment-glint
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab387, Mac mini); main merged in since, CI green at this head.
2026-10-27 12:40:44 +01:00
Ludovic Marie 869efb87d2 Merge pull request #2130 from Joansitoh/fix/launcher-text-field-edit
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab387, Mac mini); main merged in since, CI green at this head.
2026-10-27 12:40:40 +01:00
Ludovic MarieandClaude Opus 5.5 2edb5193ef Merge main into fix/wither-rose-kills (combat: keep hive_emptying_blast and explosion_owner; wither: the skull's owner set around main's hive-emptying blast)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:32:55 +01:00
Ludovic Marie d9f49ecec0 Merge pull request #2261 from kacpherek/fix/death-and-hurt-effects
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (72 kacpherek PRs incl. this one) playhorizon 94/95 + Mac mini 33/33, lan_e2e 23/23 both ).
2026-10-27 12:30:46 +01:00
Ludovic Marie 73d0cd36e8 Merge pull request #2257 from kacpherek/fix/container-open-sound-pitch
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (72 kacpherek PRs incl. this one) playhorizon 94/95 + Mac mini 33/33, lan_e2e 23/23 both ).
2026-10-27 12:30:43 +01:00
Ludovic Marie abda92d574 Merge pull request #2251 from kacpherek/fix/brewed-potion-trigger
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (72 kacpherek PRs incl. this one) playhorizon 94/95 + Mac mini 33/33, lan_e2e 23/23 both ).
2026-10-27 12:30:39 +01:00
Ludovic MarieandClaude Opus 5.5 cacd39338f Merge origin/main into fix/rain-sound-vanilla
Keeps main's Decreased and Minimal particle options. As in
ClientLevel.tickWeatherEffects, the rain position is set before Minimal's
break, so the rain sound still plays with Minimal particles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:30:36 +01:00
Ludovic Marie d7cd2ed5e8 Merge pull request #2229 from kacpherek/fix/fishing-rod-hooked-trigger
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (72 kacpherek PRs incl. this one) playhorizon 94/95 + Mac mini 33/33, lan_e2e 23/23 both ).
2026-10-27 12:30:34 +01:00
Ludovic Marie ca63a998cf Merge pull request #2214 from masa-masa-0314/fix/superflat-customize
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab387, Mac mini); main merged in since, CI green at this head.
2026-10-27 12:28:29 +01:00
Ludovic MarieandClaude Opus 5.5 86b651405a Merge main into fix/levitation-and-spyglass-triggers (advancements.rs: keep the levitation/using_item triggers and main's hurt triggers)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:28:06 +01:00
Ludovic MarieandClaude Opus 5.5 cf9fb96efa launcher: a vertical Sculk card for the sidebar, readable at any size
The sidebar showed Sculk's wide banner shrunk to 228 px, and its baked-in text could not be read.
It now shows a card made for the sidebar: Sculk's characters and logo cropped from its banner, and
the headline, "Ready in under a minute", a "Get my server" button and the HORIZON15 code drawn by
the launcher, so they stay sharp and follow its language (French and Russian added). Where the
window is too short for the tall card, a compact one puts the characters beside the text. The
Partner label, the 180 s before Hide, the link and the hover lift stay; the Servers page keeps
the wide banner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:26:41 +01:00
Ludovic Marie 6850ec1b3a Merge pull request #2528 from kacpherek/fix/server-mob-animation-sync
🤖 Agent: Merged main in (clean) so GitHub sees one merge base; proven in union A2 (union/cg19-a2): clippy, tests, parity, lan_e2e both paths.
2026-10-27 12:22:21 +01:00
Ludovic Marie c2d43643aa Merge pull request #2125 from kacpherek/fix/remote-entity-events
🤖 Agent: Read against the vanilla 26.3 sources (Server players see villager hearts, taming hearts and smoke, spawn poofs and snowball crumbs) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 12:22:17 +01:00
Ludovic MarieandClaude Opus 5.5 ab083f9005 Merge main into fix/enchantment-glint
entity.wgsl: the vanilla glint path also takes main's overlay 33 (a translucent item with the glint).
gfx.rs: main's energy swirl pipeline; lightning's offset stays on attribute 7 (6 is the glint UV).
item_render.rs: main's translucent item overlays, chosen with the glint UV the branch adds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:21:19 +01:00
Ludovic Marie 94290b01ee Merge pull request #2275 from kacpherek/fix/spawn-egg-baby
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:19:04 +01:00
Ludovic Marie fc6102738c Merge pull request #2266 from kacpherek/fix/tnt-minecart-collision
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:19:01 +01:00
Ludovic MarieandClaude Opus 5.5 cbbf6f8b2e Merge main into fix/launcher-text-field-edit (main.rs: keep main's background_error and the LineEdit field)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:18:50 +01:00
Ludovic Marie f00228e76a Merge pull request #2250 from kacpherek/fix/hopper-minecart-pickup
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:27 +01:00
Ludovic Marie cad80ba3aa Merge pull request #2248 from kacpherek/fix/stats-combat
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:23 +01:00
Ludovic MarieandClaude Opus 5.5 f1d14c918f Merge main into fix/superflat-customize (world_loading: keep the flat settings and main's game rules)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:18:22 +01:00
Ludovic Marie 7953969510 Merge pull request #2246 from kacpherek/fix/mob-effect-merge
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:19 +01:00
Ludovic Marie b62c2854ad Merge pull request #2244 from kacpherek/fix/lightning-cleans-copper
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:16 +01:00
Ludovic Marie 830b841850 Merge pull request #2236 from kacpherek/fix/harvest-full-beehive
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:12 +01:00
Ludovic Marie ba01b418c2 Merge pull request #2232 from kacpherek/fix/note-block-attack-and-heads
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:22 +01:00
Ludovic Marie 5a3ea22419 Merge pull request #2230 from kacpherek/fix/player-hurt-entity-trigger
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:19 +01:00
Ludovic Marie cd43ad9a4a Merge pull request #2226 from kacpherek/fix/armadillo-scutes
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:15 +01:00
Ludovic Marie 98a11014ea Merge pull request #2223 from kacpherek/fix/invisibility-mob-detection
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:11 +01:00
Ludovic Marie 467e28f2eb Merge pull request #2220 from kacpherek/fix/explosion-beehive-bees
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:07 +01:00
Ludovic Marie 069a4f7f23 Merge pull request #2128 from kacpherek/fix/remote-hurt-tilt
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:16:24 +01:00
Ludovic Marie f78ba8c75f Merge pull request #2123 from kacpherek/fix/remote-leash-knots
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:16:20 +01:00
Ludovic Marie fdb763d5e3 Merge pull request #2493 from carlosnunez09/fix/end-gateway-exit
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab3879 (main b6f18dc8f + 57 PRs) Mac mini 122/122 delta harness).
2026-10-27 12:16:16 +01:00
Ludovic Marie 9aca5c35ca Merge pull request #2414 from mahirsn/fix/sign-editor-update
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab3879 (main b6f18dc8f + 57 PRs) Mac mini 122/122 delta harness).
2026-10-27 12:16:12 +01:00
Ludovic Marie a16d84d15d Merge pull request #2298 from carlosnunez09/fix/leave-game-statistic
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab3879 (main b6f18dc8f + 57 PRs) Mac mini 122/122 delta harness).
2026-10-27 12:16:09 +01:00
Ludovic MarieandClaude Opus 5.5 35fddca7e6 refactor: the options screen's Opt enum lives in settings.rs
graphics_ui named options_ui::Opt for its rows, which made it depend on
options_ui, one of the edges that keep the client lib in a single cycle.
The enum (the option ids) moves to settings.rs; options_ui re-exports it
and keeps its methods. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:15:36 +01:00
Ludovic MarieandClaude Opus 5.5 90a3fc82aa refactor: HealthRules lives in world.rs
World holds the health rules but they were defined in health.rs, which made
world depend on health, one of the edges that keep the client lib in a
single cycle. The plain data type (and its Default) moves to world.rs and
health re-exports it. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:15:36 +01:00
Ludovic MarieandClaude Opus 5.5 84c631a88b refactor: the villager Poi enum lives in poi_sections.rs
World's POI sections are typed by mobs::villager_brain::Poi, which made
world depend on mobs, one of the edges that keep the client lib in a single
cycle. The enum moves to poi_sections.rs; villager_brain re-exports it and
keeps its methods. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:15:36 +01:00
Ludovic MarieandClaude Opus 5.5 83bdb31eea refactor: SavedProto lives in chunk.rs
streaming's ChunkGenerator trait named save::SavedProto, which made
streaming depend on save, one of the edges that keep the client lib in a
single cycle. The plain data type moves beside ChunkExtras in chunk.rs and
save re-exports it. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:15:35 +01:00
Ludovic Marie 8d1df095e7 Merge pull request #2521 from kacpherek/feat/energy-swirl
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Scroll and add the energy swirl of charged creepers and powered withers. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs.
2026-10-27 12:13:48 +01:00
Ludovic Marie 1ac76ac103 Merge pull request #2503 from kacpherek/fix/particles-option-level
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Make the Particles option thin all particles, explosion debris and rain splashes. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs.
2026-10-27 12:13:43 +01:00
Ludovic MarieandClaude Opus 5.5 4a82c014bb Merge main into fix/server-mob-animation-sync
Brings main in so the branch has one merge base with it (GitHub saw a
conflict through the criss-cross bases; the merge itself is clean).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:08:05 +01:00
Ludovic Marie cd4ff3828b Merge pull request #2555 from kacpherek/fix/2546-bubble-column-entities
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Bubble columns lift and drag mobs, items and ender pearls. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs.
2026-10-27 12:07:29 +01:00
Ludovic Marie 2a6b5836a9 Merge pull request #2513 from kacpherek/fix/desert-leaves-ambient
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Play the sand idle and poplar leaves ambient sounds. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs.
2026-10-27 12:07:25 +01:00
Ludovic MarieandClaude Opus 5.5 380c40562d Merge main into fix/dragon-kill-credit
main's dragon hurt sound resets ambient_time, so the dragon is borrowed
mutably there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:05:03 +01:00
Ludovic MarieandClaude Opus 5.5 f010708103 Merge main into fix/immediate-respawn
throw_from_eyes takes thrown_from_hand now (main): the cursor stack is
dropped with drop(carried, false).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:03:59 +01:00
Ludovic Marie 9c62aba384 Merge pull request #2729 from ludovic111/refactor/cut-pack-pattern
🤖 Agent: verify passed (after merging main for the rabbit-arm clippy fix). Move only: the pack.mcmeta filter regex goes from packs/pattern.rs to horizon-client-core (re-exported), cutting atlas_sources -> packs (step A). pr_ready --quick green (1352 tests). Ready to merge once parity is green.
2026-10-27 11:59:25 +01:00
Ludovic MarieandClaude Opus 5.5 7d08cdf860 Merge main into fix/mob-ambient-particles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:59:23 +01:00
Ludovic Marie c6c2ec5f9f Merge pull request #2744 from ludovic111/refactor/cut-chunk-extras
🤖 Agent: verify passed. Move only: ChunkExtras and RolledLoot go from save.rs to chunk.rs (re-exported from save), cutting chunk -> save (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-27 11:59:22 +01:00
Ludovic MarieandClaude Opus 5.5 01e6dae6f7 Merge main into fix/shears-wear
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:56 +01:00
Ludovic MarieandClaude Opus 5.5 ede89f4d37 Merge main into fix/mace-smash-knockback-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:38 +01:00
Ludovic MarieandClaude Opus 5.5 033d704ac5 Merge main into fix/cat-spawner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:25 +01:00
Ludovic MarieandClaude Opus 5.5 fec49b4482 Merge origin/main into fix/death-and-hurt-effects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:01 +01:00
Ludovic MarieandClaude Opus 5.5 86e5db17b5 Merge origin/main into fix/container-open-sound-pitch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:01 +01:00
Ludovic MarieandClaude Opus 5.5 9f04b02e12 Merge origin/main into fix/brewed-potion-trigger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:00 +01:00
Ludovic MarieandClaude Opus 5.5 0239df6f9d Merge origin/main into fix/fishing-rod-hooked-trigger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:56:59 +01:00
Ludovic Marie de1a7b6954 Merge pull request #2482 from levzzz5154/fix/piston-entity-push
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head (checked against the head by contrib-gate-20). Re-review was asked about 07:30Z with no new objection. Proven in wave 5's union run (main + 54 PRs, 332/332 selected harnesses; lan_e2e 18/18 in-frame and on the server thread). Thank you!
2026-10-27 11:54:19 +01:00
Ludovic Marie bcd92633ac Merge pull request #2708 from ludovic111/refactor/cut-model-library-test
🤖 Agent: verify passed. A test move only: the item_render error-order check goes from model_library's test to item_render's tests, cutting model_library -> item_render (step A of the cycle plan). pr_ready --quick green (1316 tests). Ready to merge.
2026-10-27 11:49:41 +01:00
Ludovic Marie f065d0d11d Merge pull request #2537 from kacpherek/feat/glowing
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw entity outlines for the Glowing effect. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 11:49:37 +01:00
Ludovic MarieandClaude Opus 5.5 32a7a4c730 Merge origin/main into fix/death-and-hurt-effects
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:56 +01:00
Ludovic MarieandClaude Opus 5.5 1b9f8cab58 Merge origin/main into fix/container-open-sound-pitch
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:54 +01:00
Ludovic MarieandClaude Opus 5.5 d558a4abf3 Merge origin/main into fix/brewed-potion-trigger
advancements.rs keeps main's trade trigger and the PR's own trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:36 +01:00
Ludovic MarieandClaude Opus 5.5 95000c906a Merge origin/main into fix/fishing-rod-hooked-trigger
advancements.rs keeps main's trade trigger and the PR's fishing rod trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:46:59 +01:00
Ludovic Marie 512545ade7 Merge pull request #2554 from kacpherek/fix/2549-mob-water-push
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Mobs are pushed by flowing water and lava. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 11:44:09 +01:00
Ludovic Marie 33afb63acb Merge pull request #2313 from socksprox/fix/player-head-textures
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head (checked against the head by contrib-gate-20). Re-review was asked about 07:30Z with no new objection. Proven in wave 5's union run (main + 54 PRs, 332/332 selected harnesses; lan_e2e 18/18 in-frame and on the server thread). Thank you!
2026-10-27 11:42:39 +01:00
Ludovic Marie 26b172eb1d Merge remote-tracking branch 'origin/main' into refactor/cut-pack-pattern 2026-10-27 11:42:20 +01:00
Ludovic Marie ac42f11d7a Merge pull request #2280 from kacpherek/fix/elytra-firework-trail
🤖 Agent: Read against the vanilla 26.3 sources (Firework rockets: smooth flight and spark trail on a server) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:37:49 +01:00
Ludovic Marie 3690dba775 Merge pull request #2260 from kacpherek/fix/dragon-ambient-sound
🤖 Agent: Read against the vanilla 26.3 sources (Give the Ender Dragon its ambient sound) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:37:45 +01:00
Ludovic Marie 7f01cb4f10 Merge pull request #2255 from kacpherek/fix/camel-ai-speeds
🤖 Agent: Read against the vanilla 26.3 sources (Walk, follow and flee at the camel's real AI speeds) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:37:42 +01:00
Ludovic MarieandClaude Opus 5.5 627ab546b9 Merge origin/main into fix/remote-entity-events (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:27 +01:00
Ludovic MarieandClaude Opus 5.5 751f242824 Merge origin/main into fix/rain-sound-vanilla
The rain sound stays where the PR moved it (tickRainParticles, from the
splash loop's random); main's copy in the tick is dropped. Main's Horizon
Weather gate covers the whole block, so the option still silences the rain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:36:13 +01:00
Ludovic Marie 6817cb4af0 Merge pull request #2253 from kacpherek/fix/item-used-on-block-trigger
🤖 Agent: Read against the vanilla 26.3 sources (Fire item_used_on_block for block transforms, waxing, jukebox discs and vault keys) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:35:01 +01:00
Ludovic Marie c2fb0b6730 Merge pull request #2249 from kacpherek/fix/copper-golem-struck-by-lightning
🤖 Agent: Read against the vanilla 26.3 sources (Lightning takes a copper golem back one oxidation step) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:34:58 +01:00
Ludovic Marie eb03afb3d4 Merge pull request #2247 from kacpherek/fix/ambient-intervals
🤖 Agent: Read against the vanilla 26.3 sources (Use vanilla ambient sound intervals for guardians, hoglins, shulkers and allays) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:34:54 +01:00
Ludovic Marie 07e317d350 Merge pull request #2242 from kacpherek/fix/absorption-effect-ends
🤖 Agent: Read against the vanilla 26.3 sources (End the Absorption effect once the absorption is used up) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:34:51 +01:00
Ludovic Marie 4023708865 Merge pull request #2241 from kacpherek/fix/stats-block-interactions
🤖 Agent: Read against the vanilla 26.3 sources (Count cake, cauldron, note block, bell, ender chest and jukebox statistics) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:34:46 +01:00
Ludovic Marie dc5d571b64 Merge pull request #2307 from socksprox/fix/server-pack-reload-progress
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head (checked against the head by contrib-gate-20). Re-review was asked about 07:30Z with no new objection. Proven in wave 5's union run (main + 54 PRs, 332/332 selected harnesses; lan_e2e 18/18 in-frame and on the server thread). Thank you!
2026-10-27 11:31:04 +01:00
Ludovic Marie 7d80fe24f2 Merge pull request #2271 from kacpherek/fix/2213-frog-speed
🤖 Agent: Read against the vanilla 26.3 sources (Frogs walk at vanilla speed (smooth swimming move control)) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:41 +01:00
Ludovic Marie 212a7ec198 Merge pull request #2267 from kacpherek/fix/allay-delivery-and-death-drops
🤖 Agent: Read against the vanilla 26.3 sources (Make allays drop what they carry on death and use the default pickup delay when throwing) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:37 +01:00
Ludovic Marie 6fb56a9190 Merge pull request #2265 from kacpherek/fix/frog-fall-damage
🤖 Agent: Read against the vanilla 26.3 sources (Let frogs take fall damage five points below the base amount) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:33 +01:00
Ludovic Marie 7b0db2d5ec Merge pull request #2264 from kacpherek/fix/bee-sedated-stay-out
🤖 Agent: Read against the vanilla 26.3 sources (Bees let out of a sedated hive stay out of hives for 20 seconds) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:30 +01:00
Ludovic Marie 2e73331cf6 Merge pull request #2263 from kacpherek/fix/bee-poison-difficulty
🤖 Agent: Read against the vanilla 26.3 sources (Bee stings only poison on Normal and Hard) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:26 +01:00
Ludovic Marie 217a780f28 Merge pull request #2716 from RoyalPixelsDE/fix/duplicate-rabbit-arm
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). This fixes main's clippy failure: save_mob's second minecraft:rabbit arm is unreachable, and the first one already writes RabbitType and MoreCarrotTicks. Verify is green. Thanks @FusselTV for catching it first!
2026-10-27 11:28:00 +01:00
Ludovic Marie 92167dab3c Merge pull request #2240 from kacpherek/fix/goat-breed-milk
🤖 Agent: Read against the vanilla 26.3 sources (Let goats breed, follow wheat and be milked) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:45 +01:00
Ludovic Marie aea024d615 Merge pull request #2239 from kacpherek/fix/breeze-shot-spread-and-aim
🤖 Agent: Read against the vanilla 26.3 sources (Make breeze shots follow the world difficulty and aim higher at riders) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:42 +01:00
Ludovic Marie 9d19e22efe Merge pull request #2238 from kacpherek/fix/partial-pickup-pitch
🤖 Agent: Read against the vanilla 26.3 sources (Give a partial item pickup the random pitch of a full pickup) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:38 +01:00
Ludovic Marie 1d878f41fd Merge pull request #2237 from kacpherek/fix/detector-rail-container-signal
🤖 Agent: Read against the vanilla 26.3 sources (Make a detector rail's comparator read the minecart on it) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:34 +01:00
Ludovic Marie a541cbb77b Merge pull request #2222 from kacpherek/fix/tadpole-slime-ball-feeding
🤖 Agent: Read against the vanilla 26.3 sources (Let tadpoles be fed slime balls to grow up faster) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:31 +01:00
Ludovic MarieandClaude Opus 5.5 6a56f7e39f Merge origin/main into fix/remote-entity-events
remote.rs keeps both the PR's entity-event particles (20, 6/7/12/13/14/18, 3)
and main's drowning event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:25:46 +01:00
Ludovic Marie 88b3b3e879 Merge pull request #2234 from kacpherek/fix/armadillo-brush
🤖 Agent: Read against the vanilla 26.3 sources (Let a brush take a scute off an armadillo) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:55 +01:00
Ludovic Marie 2e5e61600a Merge pull request #2228 from kacpherek/fix/stats-talked-to-villager
🤖 Agent: Read against the vanilla 26.3 sources (Count talking to villagers under vanilla's statistic name) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:51 +01:00
Ludovic Marie 517c54c427 Merge pull request #2227 from kacpherek/fix/happy-ghast-camera-distance
🤖 Agent: Read against the vanilla 26.3 sources (Pull the third-person camera back further when riding a happy ghast) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:48 +01:00
Ludovic Marie 48d95663d6 Merge pull request #2225 from kacpherek/fix/sign-reopen-and-applicators
🤖 Agent: Read against the vanilla 26.3 sources (Signs reopen their editor on right click and take dyes, ink sacs and honeycomb) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:44 +01:00
Ludovic Marie 3c53df3100 Merge pull request #2224 from kacpherek/fix/stats-movement
🤖 Agent: Read against the vanilla 26.3 sources (Count fall, riding and flying statistics as vanilla does) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:40 +01:00
Ludovic Marie 22d72feae8 Merge pull request #2221 from kacpherek/fix/villager-trade-trigger
🤖 Agent: Read against the vanilla 26.3 sources (Fire the villager_trade advancement trigger on trades) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:51 +01:00
Ludovic Marie bd1f66f5ca Merge pull request #2219 from kacpherek/fix/minecart-dismount-direction
🤖 Agent: Read against the vanilla 26.3 sources (Step out of a minecart in vanilla's order) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:47 +01:00
Ludovic Marie df7103ef02 Merge pull request #2218 from kacpherek/fix/copper-golem-chest-comparator
🤖 Agent: Read against the vanilla 26.3 sources (Update comparators when a copper golem moves items in or out of a chest) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:43 +01:00
Ludovic Marie 23db3397a7 Merge pull request #2136 from kacpherek/fix/furnace-wet-sponge-bucket
🤖 Agent: Read against the vanilla 26.3 sources (Furnaces: drying a wet sponge fills the bucket in the fuel slot) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:39 +01:00
Ludovic Marie d713ec4c92 Merge pull request #2135 from kacpherek/fix/infested-blocks-silverfish
🤖 Agent: Read against the vanilla 26.3 sources (Infested blocks release their silverfish when broken) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:35 +01:00
Ludovic Marie 1e9eca2dcf Merge pull request #2133 from kacpherek/fix/damage-gamerules
🤖 Agent: Read against the vanilla 26.3 sources (Make the fall, drowning, fire and freeze damage game rules work) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:51 +01:00
Ludovic Marie 7f7e591c8a Merge pull request #2132 from kacpherek/fix/suspicious-stew-effects
🤖 Agent: Read against the vanilla 26.3 sources (Apply suspicious stew effects when the stew is eaten) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:47 +01:00
Ludovic Marie f41f360288 Merge pull request #2129 from kacpherek/fix/server-sent-particles
🤖 Agent: Read against the vanilla 26.3 sources (Server players see sweep attacks, damage indicators and fishing particles) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:44 +01:00
Ludovic Marie b8fdda0de9 Merge pull request #2126 from kacpherek/fix/missing-level-events
🤖 Agent: Read against the vanilla 26.3 sources (Draw the level events the client dropped: lava and torch smoke, drips, chorus, sniffer eggs, dragon fireball burst) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:40 +01:00
Ludovic Marie 3e22b6e383 Merge pull request #1969 from kacpherek/fix/1302-speed-after-travel
🤖 Agent: Read against the vanilla 26.3 sources (Give a server player's new body its effects at once after a level change) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:36 +01:00
Ludovic MarieandClaude Opus 5.5 f2070cef54 Merge main into fix/chat-links
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:18:01 +01:00
Ludovic MarieandClaude Opus 5.5 648060047c Merge main into fix/particles-option-level
main moved the cloud's client tick into cloud_client_tick; its particles
stay always visible (addAlwaysVisibleParticle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:17:55 +01:00
Ludovic MarieandClaude Opus 5.5 92f33c89fb Merge main into fix/2546-bubble-column-entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:17:27 +01:00
Ludovic MarieandClaude Opus 5.5 bcb539057d Merge main into fix/desert-leaves-ambient
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:17:26 +01:00
Ludovic MarieandClaude Opus 5.5 e2b35bc827 Merge main into feat/energy-swirl
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:17:25 +01:00
Ludovic Marie d9b6483aa2 Merge pull request #2268 from kacpherek/fix/minecart-chest-sneak-open
🤖 Agent: Read against the vanilla 26.3 sources (Let a sneaking player open chest and hopper minecarts) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:14:07 +01:00
Ludovic Marie e8c1d1219d Merge pull request #2134 from kacpherek/fix/melee-exhaustion-on-hit
🤖 Agent: Read against the vanilla 26.3 sources (Charge melee exhaustion only when the hit hurts) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:13:02 +01:00
Ludovic MarieandClaude Opus 5.5 8601d34f20 Merge main into fix/particles-option-level
The shulker's teleport particles (event 2016, from main) take the
engine's own particle level like the other block_teleportation calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:13:01 +01:00
Ludovic Marie ac10eff413 Merge pull request #2119 from kacpherek/fix/1893-crystal-explosion
🤖 Agent: Read against the vanilla 26.3 sources (End crystal explosion shows its particles on the server thread) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:12:58 +01:00
Ludovic Marie 59d6b525ce Merge pull request #2116 from kacpherek/fix/2095-signal-save
🤖 Agent: Read against the vanilla 26.3 sources (Save the world on SIGTERM, Ctrl+C and SIGHUP) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:12:54 +01:00
Ludovic Marie d812712ded Merge remote-tracking branch 'origin/main' into HEAD 2026-10-27 11:12:50 +01:00
Ludovic Marie d24f9f5949 Merge pull request #2115 from kacpherek/fix/1361-lingering-cloud
🤖 Agent: Read against the vanilla 26.3 sources (Lingering potions leave an effect cloud) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:12:50 +01:00
Ludovic Marie 167b18d7d0 Merge pull request #2111 from kacpherek/fix/2067-deflect-fireballs
🤖 Agent: Read against the vanilla 26.3 sources (Show a hit-back fireball turning at once) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:12:46 +01:00
Ludovic MarieandClaude Opus 5.5 ce94056986 Merge main into fix/chat-links
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:12:06 +01:00
Ludovic MarieandClaude Opus 5.5 d50a5e1f64 refactor: ChunkExtras and RolledLoot live in chunk.rs
The chunk stores them, but they were defined in save.rs, which made chunk.rs
depend on save, one of the edges that keep the client lib in a single cycle.
They move to chunk.rs and save re-exports them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:11:39 +01:00
FusselTVandClaude Opus 5.5 07e935d0bf Mobs: drop the unreachable second rabbit arm in mob_to_nbt
#2585 and 0d496fdc8 both added a "minecraft:rabbit" arm to mob_to_nbt;
the first writes RabbitType and MoreCarrotTicks, so the second is never
reached and clippy -D warnings fails on main (unreachable_patterns).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 10:29:53 +01:00
Ludovic Marie 12e0094f1f Merge pull request #2707 from ludovic111/refactor/cut-sprites-gfx
🤖 Agent: verify passed at 42c653be0. Move only: sprite_mipmap_limit and its test go from gfx.rs to sprites.rs, cutting sprites -> gfx (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-27 09:47:41 +01:00
Ludovic Marie 1586018c65 Merge pull request #2534 from kacpherek/feat/bidi-text
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw Arabic, Persian and Hebrew text joined and right to left. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes. I merged main in (no conflicts).
2026-10-27 09:40:02 +01:00
Ludovic Marie 8457b35d8a Merge pull request #2548 from kacpherek/fix/body-saddle-equipment-sync
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Send body armour, carpets and saddles to the single player window. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:39:59 +01:00
Ludovic Marie 830b6890e4 Merge pull request #2507 from kacpherek/feat/create-world-rules
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Create New World: edit the game rules before creating the world. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:39 +01:00
Ludovic Marie 0e793be2ac Merge pull request #2547 from kacpherek/fix/variant-textures
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw mob variants: rabbit, parrot, axolotl, salmon size and baby models. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:34 +01:00
Ludovic Marie 324a170a84 Merge pull request #2553 from kacpherek/fix/save-effects-teams
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Save and load a mob's active effects. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:30 +01:00
Ludovic Marie 84fc738ae5 Merge pull request #2531 from kacpherek/fix/f3b-all-hitboxes
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw F3+B hitboxes for every entity. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:27 +01:00
Ludovic Marie 5fac4f0f34 Merge pull request #2525 from kacpherek/fix/player-pose-dimensions
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Size players' boxes by pose, including sleeping. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:23 +01:00
Ludovic MarieandClaude Opus 5.5 86d90c38a9 refactor: the pack filter regex lives in horizon-client-core
packs/pattern.rs is self-contained; atlas_sources reached into packs for it,
one of the edges that keep the client lib in a single cycle. It moves to
horizon-client-core and packs re-exports it under its old path. No
behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:34:22 +01:00
Ludovic Marie d65bbfc30f Merge pull request #2526 from kacpherek/fix/mob-freeze-strider-shake
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Freeze mobs in powder snow and shake frozen mobs and cold striders. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:54 +01:00
Ludovic Marie 784b586757 Merge pull request #2524 from kacpherek/fix/guardian-bubbles
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw the guardian's swimming bubbles and the bubble trail along its beam. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:51 +01:00
Ludovic Marie 71a1181b10 Merge pull request #2518 from kacpherek/fix/block-outline-rules
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Follow vanilla's block outline rules: high contrast, F1 and adventure. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:48 +01:00
Ludovic Marie 45b80deb02 Merge pull request #2517 from kacpherek/fix/drowning-bubbles
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Send the drowning entity event (67) and draw the bubbles on clients. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:44 +01:00
Ludovic Marie 4ae877ab3b Merge pull request #2514 from kacpherek/fix/amethyst-step-chime
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Play the amethyst chime when walking on amethyst blocks. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:40 +01:00
Ludovic Marie 8aeb393c54 Merge pull request #2516 from kacpherek/fix/block-ambient-sponge-brushable
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Wet sponges drip water and suspicious sand and gravel shed dust. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:31:05 +01:00
Ludovic Marie 5cf1da78e8 Merge pull request #2510 from kacpherek/fix/projectile-smoke-trail
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw the smoke trail behind wither skulls and fireballs, and the wither's head smoke on a client. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:31:02 +01:00
Ludovic Marie 2779c481ad Merge pull request #2498 from kacpherek/fix/2494-command-block-use
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Open the command block screen on a server's client and send its edits. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:30:58 +01:00
Ludovic Marie 932a2215fc Merge pull request #2492 from kacpherek/fix/2487-glass-item-water
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Show water through dropped stained glass items. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:29:50 +01:00
Ludovic MarieandClaude Opus 5.5 ae713f7bd8 refactor: item_render's error-order check lives in item_render's tests
model_library's test ended with a check of ItemRenderer::load_with_library,
which made model_library depend on item_render, one of the edges that keep
the client lib in a single cycle. The check moves to item_render's tests
with its own fixture. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:27:51 +01:00
Ludovic Marie cb574cdfdc Merge pull request #2509 from kacpherek/fix/lan-entity-event-particles
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Villagers in an ongoing raid splash for guests too (entity event 42). I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:27:02 +01:00
Ludovic Marie b6ee9b1de8 Merge pull request #2569 from kacpherek/fix/rain-at-game
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Check rain at the player's and arrow's position. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:26:58 +01:00
Ludovic Marie 107a25bee3 Merge pull request #2567 from kacpherek/fix/rain-at-mobs
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Check rain at the mob's position, not the global weather. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:26:55 +01:00
Ludovic Marie 9ba8223b40 Merge pull request #2572 from kacpherek/fix/2571-difficulty-persist
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Save and load the difficulty through level.dat's difficulty_settings. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:26:51 +01:00
Ludovic Marie 345650c697 Merge pull request #2529 from kacpherek/fix/head-rotation-limits
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Use vanilla's head turn limits for some mobs and show a parrot in water as flying. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:26:46 +01:00
Ludovic Marie 8f8aecd2c8 Merge pull request #2504 from kacpherek/fix/rain-splash-radius
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Use the Weather Radius for rain splashes and let the Weather option stop them. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:23:53 +01:00
Ludovic Marie 7495681901 Merge pull request #2523 from kacpherek/fix/framed-map-decorations
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Hide player arrows on a map in an item frame. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:23:13 +01:00
Ludovic Marie 3c820a0751 Merge pull request #2520 from kacpherek/fix/mob-step-sounds
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Fix turtle step timing and camel sand step sounds. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:23:01 +01:00
Ludovic Marie a1fa0149ff Merge pull request #2519 from kacpherek/fix/voice-pitch
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Fix the baby zombie villager, happy ghast and player hurt voice pitch. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:22:45 +01:00
Ludovic MarieandClaude Opus 5.5 1d018614ac Merge main into fix/sync-mob-variants
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:21:40 +01:00
Ludovic Marie 653e3a4ee8 Merge main into feat/panda-states
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/control.rs
2026-10-27 09:21:39 +01:00
Ludovic Marie fd915f8a5d Merge main into feat/fox-states
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/control.rs
2026-10-27 09:21:37 +01:00
Ludovic Marie 0504663c41 Merge main into fix/goat-horns-ram
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 09:21:36 +01:00
Ludovic MarieandClaude Opus 5.5 9203f21b99 Merge main into fix/frog-croak-tongue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:21:25 +01:00
Ludovic Marie 167c3d14bd Merge pull request #2522 from kacpherek/fix/level-up-sound
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Play the level-up sound for each level a gain crosses. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:16 +01:00
Ludovic Marie 152fe2bb0a Merge pull request #2508 from kacpherek/fix/step-sound-discrete
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Play player step sounds while crawling under a low ceiling. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:13 +01:00
Ludovic Marie 6fd328c602 Merge pull request #2512 from kacpherek/fix/end-flash-hide-lightning
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Stop End flashes lighting the scene when Hide Lightning Flashes is on. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:09 +01:00
Ludovic Marie 5c5ede59c1 Merge pull request #2511 from kacpherek/fix/night-vision-partial-tick
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Ease the night vision pulse with the partial tick. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:05 +01:00
Ludovic Marie 9dda8e0678 Merge pull request #2505 from kacpherek/fix/chunk-builder-preset
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Set the preset to Custom when Chunk Builder changes. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:02 +01:00
Ludovic MarieandClaude Opus 5.5 42c653be00 refactor: sprite_mipmap_limit lives in sprites.rs
sprites.rs called back into gfx for this one function, one of the edges that
keep the client lib's modules in a single cycle. The function and its test
move to sprites.rs; gfx calls it there. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:18:56 +01:00
Ludovic MarieandClaude Opus 5.5 6b5963d111 Merge main into feat/bidi-text
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:17:32 +01:00
Ludovic MarieandClaude Opus 5.5 9ac405e9cc Merge main into fix/armadillo-roll
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:15:58 +01:00
Ludovic MarieandClaude Opus 5.5 903b427006 Merge main into fix/server-mob-animation-sync-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:15:58 +01:00
Ludovic MarieandClaude Opus 5.5 2ce21900ae Merge main into fix/server-mob-animation-sync
main's ease_peek and onPeekAmountChange; client_tick eases with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:15:57 +01:00
Ludovic MarieandClaude Opus 5.5 03cbb48fa4 Merge main into feat/glowing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:15:25 +01:00
Ludovic Marie 1284f97df6 Merge pull request #2580 from kacpherek/fix/2578-vex-no-suffocation
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Stop vexes suffocating inside blocks. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:15:07 +01:00
Ludovic MarieandClaude Opus 5.5 42cff7bbc2 Merge main into fix/2549-mob-water-push
main's jumpOutOfFluid (isFree with containsAnyLiquid and the shulker
collision) replaces this branch's is_free.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:13:52 +01:00
Ludovic MarieandClaude Opus 5.5 604025a53b Leave the default (temperate) variants out of the pairing data
Pig, cow, chicken, frog and zombie nautilus define DATA_VARIANT_ID as
their registry's TEMPERATE, so vanilla's getNonDefaultValues leaves it
out of the add packet; server_entities caught the extra entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:10:48 +01:00
Ludovic Marie 861ef16307 Merge pull request #2695 from levzzz5154/fix/vex-held-item-scale
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the vex holds its item at 0.55 scale with VexModel's offset (includes @kacpherek's #2560). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:09:35 +01:00
Ludovic Marie 66294b102a Merge pull request #2693 from levzzz5154/fix/arrow-death-regression-test
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the #2141 regression test. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:09:31 +01:00
Ludovic Marie 5f98d0edfe Merge pull request #2638 from senjinthedragon/hdr-vignette
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): HDR light sources under the vignette (off by default; no HDR display here). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @senjinthedragon!
2026-10-27 09:08:20 +01:00
Ludovic MarieandClaude Opus 5.5 523280e781 Draw the new level-event sound pitches from the client level's random
LevelEventHandler plays 1501, 1502, 1046 and 1047 with
ClientLevel.getRandom(), not the server level's random: drawing them from
the world's random moved the level random in use_item_on's respawn
anchor cases (12 of 4000 differed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:07:42 +01:00
Ludovic Marie 1a0b4ea7e3 Merge pull request #2615 from sifi11/shadow-depth-bias
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the shadow depth bias: shadows look the same as before on Metal and no rim flicker showed before or after (0 flipped pixels over 7 sub-pixel yaw steps). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @sifi11!
2026-10-27 09:06:07 +01:00
Ludovic Marie 6330080280 Merge pull request #2581 from levzzz5154/fix/selector-cross-level
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): unbounded selectors reach the other loaded levels; includes #2559 (End crystals and the dragon) and a main merge with the score-holder and execute origin arms. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:06:04 +01:00
Ludovic Marie 3738d8a668 Merge pull request #2579 from levzzz5154/fix/2213-frog-movement
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): frogs walk at vanilla's speed. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:06:00 +01:00
Ludovic Marie c1a7b8ab9f Merge pull request #2576 from levzzz5154/fix/benchmark-load-gate
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the benchmark's load gate waits for its chunks. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:05:56 +01:00
Ludovic Marie 28c78e8da8 Merge pull request #2575 from levzzz5154/fix/mining-dig-stop-check
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the dig stop is covered in the LAN harness (lan_e2e dig_stop passes; includes #2563). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:05:53 +01:00
Ludovic Marie 1e5ee00bcb Merge pull request #2573 from levzzz5154/fix/villager-job-site-trades
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): a villager that loses its job site offers its new trades. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:03:43 +01:00
Ludovic Marie b338c4e7c2 Merge pull request #2563 from levzzz5154/fix/thrown-projectile-flight
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): thrown items fly between server and client (lan_e2e thrown passes). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:03:39 +01:00
Ludovic Marie d53a0adb41 Merge pull request #2562 from levzzz5154/fix/potion-contents-in-the-creative-inventory
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): creative potions keep their contents (includes #2561). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:03:36 +01:00
Ludovic Marie 91d84ba3c2 Merge pull request #2561 from levzzz5154/fix/container-press-state-lifetime
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the container press state ends with the screen (lan_e2e chest, containers pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:03:32 +01:00
Ludovic Marie 4234918255 Merge pull request #2550 from levzzz5154/fix/sprint-state-persistence
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): sprint-state regression tests. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:03:28 +01:00
Ludovic Marie 4af2c671c7 Merge pull request #2634 from ASOwnerYT/claude/1515-end-portal-entities-643979
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): items and mobs go through End portals. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @ASOwnerYT!
2026-10-27 08:59:20 +01:00
Ludovic Marie e92c9ea743 Merge pull request #2610 from DanielGodriNeto/turbo/pr-thread-cpu
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): Windows thread CPU time: checked and clippy-clean for x86_64-pc-windows-msvc from the Mac mini; not run on Windows. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @DanielGodriNeto!
2026-10-27 08:59:16 +01:00
Ludovic Marie 9635fbb0a9 Merge pull request #2605 from socksprox/fix/client-effects-cleared-on-join
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): a client's effects are forgotten on join; the client keeps ticking effects until the server removes them, as vanilla (lan_e2e effects passes). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:59:12 +01:00
Ludovic Marie b409d5f624 Merge pull request #2585 from RoyalPixelsDE/fix/fox-rabbit-goat-fields
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): foxes, rabbits, goats and horses keep their state (includes #2541). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:59:09 +01:00
Ludovic Marie 9502716a07 Merge pull request #2704 from ludovic111/test/mod-content-shared-assets
🤖 Agent: verify passed at 80751276f (7 min 38 s, in the range of the last merges: 5-7.5 min). Parity scope selected no harness. Test-only change: the 5 full-content-root mod_content tests are #[ignore]d, and pr_ready runs them when the diff touches what they load. Ready to merge.
2026-10-27 08:56:10 +01:00
Ludovic Marie 723ab2aa50 Merge pull request #2613 from socksprox/t3/anticheat-parity-elytra-swap-noslow
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): movement, offhand swap, eat gating and farmland push (includes #2564; lan_e2e eating and helduse pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:56:06 +01:00
Ludovic Marie 4d5ee970d7 Merge pull request #2536 from Murilinho145SG/feat/launcher-wayland-controls
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): Wayland window controls: launcher clippy and tests pass on macOS and the Linux build type-checks; playhorizon lacks the xcb/xkbcommon libraries to link and run it. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Murilinho145SG!
2026-10-27 08:55:12 +01:00
Ludovic Marie b4db58ab8e Merge pull request #2413 from mahirsn/fix/set-held-slot-echo
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): held-slot answer as vanilla. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @mahirsn!
2026-10-27 08:55:09 +01:00
Ludovic Marie 3833886df3 Merge pull request #2320 from pqhaz3925/fix/server-book-pages
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): server books: page indicator and component-list pages. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @pqhaz3925!
2026-10-27 08:55:04 +01:00
Ludovic Marie 104ef68ee6 Merge pull request #2315 from rdbtCVS/t3/reject-occupied-horse-mount
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): an occupied horse gives no inventory, feeding or ride (lan_e2e occupied_horse, riding, mounts pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:54:59 +01:00
Ludovic Marie 31ebe6db6c Merge pull request #2478 from kacpherek/feat/far-view-mod-api
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: mods can now turn on the game's own distant terrain, and Far View is an official mod. I merged main into the branch. render.set-far-terrain is now API level 7, because main added levels 4 to 6 (world, recipes, browser) in the meantime. The horizon-mods sandbox tests (32), the far-view wasm build, and clippy with the mods feature all pass. Without the mods feature, the LOD path is unchanged.
2026-10-27 08:54:55 +01:00
Ludovic Marie 9e12caaabc Merge pull request #2312 from rdbtCVS/t3/preserve-release-issue-wrapping
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): release notes keep wrapped known issues. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:48 +01:00
Ludovic Marie 23aa18322c Merge pull request #2308 from rdbtCVS/t3/recipe-book-settings-persistence
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): recipe book settings persist on the server. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:44 +01:00
Ludovic Marie 617665a176 Merge pull request #2304 from senjinthedragon/hdr-sun-mask
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): HDR marks only the sun and moon discs (off by default; no HDR display here). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @senjinthedragon!
2026-10-27 08:51:40 +01:00
Ludovic Marie 2036f0f517 Merge pull request #2292 from senjinthedragon/hdr-pq
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): HDR output builds and validates; HDR stays off by default (no HDR display here to see it). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @senjinthedragon!
2026-10-27 08:51:36 +01:00
Ludovic Marie f770f15616 Merge pull request #2272 from rdbtCVS/rdbt/fix-1705-closed-stdout-d4113cff
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): server logging survives a closed stdout. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:32 +01:00
Ludovic Marie 0b95d39d86 Merge pull request #2607 from DanielGodriNeto/turbo/pr-relight
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the relight cache: light parity harnesses and the randomized equivalence test pass; the speed-up is the author's measurement (relight -70% in a microbenchmark, no FPS change), not re-measured here. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @DanielGodriNeto!
2026-10-27 08:46:47 +01:00
Ludovic Marie 3de1a56f1f Merge pull request #2606 from socksprox/t3/fix-zombie-stuck-flowing-water
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): jumpOutOfFluid needs a dry box and no collidable entity (Entity.isFree). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:46:43 +01:00
Ludovic Marie 9dc425d357 Merge pull request #2603 from socksprox/fix/shulker-teleport-and-look
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): shulker placement, push and look rules, with no_collision counting only canBeCollidedWith entities. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:46:39 +01:00
Ludovic Marie b16ab9e8c4 Merge pull request #2565 from Louistuis/fix/turtle-feet
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): turtles walk at their own pace. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Louistuis!
2026-10-27 08:46:35 +01:00
Ludovic Marie b2edfb2c5b Merge pull request #2564 from Louistuis/fix/eat-held-after-full
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): USE_ITEM_COMPLETE finishes the use; eating on the client (lan_e2e eat_full, eating, helduse, consume-release pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Louistuis!
2026-10-27 08:46:31 +01:00
Ludovic Marie 644cca2a10 Merge pull request #2619 from RoyalPixelsDE/fix/light-edge-from-protos
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the last finished ring is lit against decorated neighbours (our follow-up #2699 carries a neighbour-check fix). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:43:03 +01:00
Ludovic Marie 07dd22212e Merge pull request #2543 from RoyalPixelsDE/fix/minecart-first-tick
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): minecarts save HasTicked and Fire. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:42:10 +01:00
Ludovic Marie 2d3b53a785 Merge pull request #2541 from RoyalPixelsDE/fix/animal-variants
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): farm animals and frogs keep their variant; sound variants saved. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:42:05 +01:00
Ludovic Marie 1e3c9c9ef7 Merge pull request #2317 from socksprox/fix/entity-view-scale
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): vanilla view scale for entity render distance. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:42:02 +01:00
Ludovic Marie d930105423 Merge pull request #2302 from socksprox/feat/server-links
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): pause menu server links. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:41:58 +01:00
Ludovic Marie a6b61e9a3c Merge pull request #2297 from ESHAYAT102/fix/cushion-break-particles
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): cushion break particles reach multiplayer players. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @ESHAYAT102!
2026-10-27 08:41:55 +01:00
Ludovic MarieandClaude Opus 5.5 80751276f3 test: run the full content-root tests only where they matter
The five mod_content tests that load the whole content root took minutes of
every `cargo test -p horizon-client --lib` on prepared data. They are now
#[ignore]d; pr_ready runs them when the diff touches what they load, and CI's
verify job runs them filtered with --ignored on its existing build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:37:40 +01:00
Ludovic MarieandClaude Opus 5.5 56b6229ef4 Merge the goat ram cooldown fix (#2545) into fix/sync-mob-variants
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:35:19 +01:00
Ludovic MarieandClaude Opus 5.5 b7d802d7a6 Merge the goat ram cooldown fix (#2545) into feat/panda-states
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:35:12 +01:00
Ludovic MarieandClaude Opus 5.5 cb246e7528 Merge the goat ram cooldown fix (#2545) into feat/fox-states
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:35:03 +01:00
Ludovic MarieandClaude Opus 5.5 05d150349f Goats: keep the ram cooldown a brain memory
RAM_COOLDOWN_TICKS is a memory in vanilla (GoatAi.initMemories,
CountDownCooldownTicks), saved with the others; as a field it was
neither saved nor seen by finalize_spawn, which failed on every goat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:34:14 +01:00
Ludovic MarieandClaude Opus 5.5 23743968e1 Merge main into feat/far-view-mod-api
render.set-far-terrain moves to API level 7: main added levels 4 to 6
(world, recipes, browser) since this branch started.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:24:26 +01:00
Ludovic Marie 6af60956ec Merge pull request #2637 from ludovic111/feat/launcher-language-home
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 08:01:36 +01:00
Ludovic Marie 8c1fa65f35 Merge pull request #2636 from ludovic111/feat/launcher-themes
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:51:55 +01:00
Ludovic Marie bc1549a438 Merge pull request #2635 from ludovic111/feat/launcher-language
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:42:16 +01:00
Ludovic MarieandClaude Opus 5.5 712306d3b9 Merge main into #2130 (launcher: the args field stays a LineEdit beside main's proxy field)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:39:43 +01:00
Ludovic Marie b6f18dc8f3 Merge pull request #2632 from ludovic111/feat/launcher-skins
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:32:21 +01:00
Ludovic Marie 504c9daf20 Merge pull request #2631 from ludovic111/feat/launcher-accounts
🤖 Agent: launcher-features-1: base main after #2627 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:21:08 +01:00
Ludovic Marie 5a6bf9705c Merge pull request #2696 from ludovic111/perf/parity-solo-window-harnesses
🤖 Agent: game-window harnesses run alone on 4-CPU machines (playhorizon runner); CI-like mix 8 of 8 on playhorizon.
2026-10-27 07:14:40 +01:00
Ludovic Marie 8f0797f258 Merge pull request #2627 from ludovic111/feat/launcher-linux-toggles
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:11:29 +01:00
Ludovic MarieandClaude Opus 5.5 ea5f288a1f Merge #2510 into fix/2546-bubble-column-entities
Both change the same lines; this head carries #2510's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:56 +01:00
Ludovic MarieandClaude Opus 5.5 db228099ab Merge #2551 into fix/sync-mob-variants
Both change the same lines; this head carries #2551's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:56 +01:00
Ludovic MarieandClaude Opus 5.5 fed7ea2275 Merge #2538 into feat/panda-states
Both change the same lines; this head carries #2538's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:56 +01:00
Ludovic MarieandClaude Opus 5.5 9ce9282b21 Merge #2545 into feat/fox-states
Both change the same lines; this head carries #2545's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:55 +01:00
Ludovic MarieandClaude Opus 5.5 2da8d6bf28 Merge #2535 into fix/frog-croak-tongue
Both change the same lines; this head carries #2535's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:54 +01:00
Ludovic MarieandClaude Opus 5.5 5b435bb03a Merge #2533 into fix/armadillo-roll
Both change the same lines; this head carries #2533's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:54 +01:00
Ludovic MarieandClaude Opus 5.5 a44d5c81a2 Merge #2539 into fix/goat-horns-ram
Both change the same lines; this head carries #2539's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:54 +01:00
Ludovic MarieandClaude Opus 5.5 101d4689f6 Merge #2528 into fix/server-mob-animation-sync-2
Both change the same lines; this head carries #2528's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 c7c890dd69 Merge #2526 into fix/server-mob-animation-sync
Both change the same lines; this head carries #2526's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 62b8f33671 Merge #2517 into fix/server-mob-animation-sync
Both change the same lines; this head carries #2517's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 48fd9f4ea5 Give the variant entries a match of their own
The fox, panda, frog, axolotl and copper golem have arms for their other
entries in other PRs (#2538, #2551, #2539, #2533): mob_variant_meta keeps the
variants out of mob_meta's match, so neither arm is unreachable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:03:55 +01:00
Ludovic Marie 8ecf5d6e47 Merge remote-tracking branch 'origin/main' into perf/parity-solo-window-harnesses 2026-10-27 07:03:34 +01:00
Ludovic MarieandClaude Opus 5.5 e42a82e9b9 Name the difficulty id field as #2239 does
#2239 adds the same LocalDifficulty field as `id`; with the same lines
here the two branches merge without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:03:08 +01:00
Ludovic MarieandClaude Opus 5.5 d77569c672 The dragon's ambient pitch draws from its own random
LivingEntity.getVoicePitch uses this.random, the same random the
ambient roll draws from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:02:35 +01:00
Ludovic MarieandClaude Opus 5.5 937dcba5b8 A TNT minecart's fuse blast uses its horizontal speed only
MinecartTNT.tick calls explode(getDeltaMovement().horizontalDistanceSqr())
when the fuse runs out; the vertical-speed branch has no vanilla source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:02:05 +01:00
levzzzandminimax-m3.1-flash e3f6f9d4dc Test the vex's small held item
`VexModel.translateToHand` draws the held item at 0.55 scale, so the
vex's sword is the tiny one vanilla shows. The test checks the scale of
the item's transform; it fails without the cull-scale (it was 1).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 11:01:50 +05:00
Ludovic MarieandClaude Opus 5.5 0f581c669e Send the axolotl's variant beside its own arm
#2533 gives the axolotl an arm for DATA_PLAYING_DEAD: a second axolotl pattern
here would be unreachable, so the variant goes before the match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:01:41 +01:00
Ludovic MarieandClaude Opus 5.5 d8b49e8697 Merge main into fix/2213-frog-speed
Keeps both registrations: the frog's move control and main's turtle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:01:02 +01:00
Ludovic Marie c1ebf5d1b0 Merge pull request #2626 from ludovic111/feat/launcher-instance-commands
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:00:52 +01:00
Ludovic MarieandClaude Opus 5.5 71b8f39eb5 Merge main into fix/allay-delivery-and-death-drops
Keeps both death drops: the allay's load and main's piglin inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:00:33 +01:00
Ludovic MarieandClaude Opus 5.5 36f6ab9839 Infested, Weaving and Wind Charged draw from the bearer's random
InfestedMobEffect.onMobHurt, WeavingMobEffect.spawnCobwebsRandomlyAround
and WindChargedMobEffect.onMobRemoved all use mob.getRandom(): a mob's
own random, the world's for the player as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:59:59 +01:00
Ludovic MarieandClaude Opus 5.5 d53114fa82 Size a drowned's thrown trident in F3+B
Main's ProjectileKind::Trident (a mob's trident) is the trident's 0.5 box with
its 0.13 eye height, as the player's tridents below.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:58:31 +01:00
Ludovic MarieandClaude Opus 5.5 d3b28edfb6 Merge main into fix/death-and-hurt-effects
The Infested event stays immediate (onMobHurt runs in hurtServer); the
other events a hit raises are left in the outbox for the end of the
tick, as on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:58:20 +01:00
Ludovic Marie 9b5c36e232 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-27 06:58:17 +01:00
Ludovic MarieandClaude Opus 5.5 9c49424b4d Merge main into fix/wither-rose-kills
A blast's attacker is the projectile's owner, else main's exploding mob
(getIndirectSourceEntity); both CombatTracker fields kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:57:29 +01:00
Ludovic MarieandClaude Opus 5.5 f4271d7c16 Merge #2492 into the energy swirl branch
#2492 takes overlay code 32 for translucent item quads (33 with glint): the
energy swirl moves to 34 and is the fifth of eight entity materials.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:59 +01:00
Ludovic MarieandClaude Opus 5.5 6a65ee1124 A guest saved by a totem is not a player kill
The guest's health goes through the same totem path as the host's
(applied on the next tick_totem), so the kill check skips a fired totem
for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:46 +01:00
Ludovic MarieandClaude Opus 5.5 ad70f755d7 Merge main into fix/stats-combat
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:43 +01:00
Ludovic MarieandClaude Opus 5.5 d2ca443d6f Merge main into fix/mob-effect-merge
Keeps this branch's add_effect (infinite durations, isShorterDurationThan)
over main's similar fix, and main's hidden_effects cleanup with infinite
effects kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:05 +01:00
Ludovic MarieandClaude Opus 5.5 64279fa1ce Merge main into fix/detector-rail-container-signal
Keeps both GameLevel fields: cart_signals and main's arrow_boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:47 +01:00
Ludovic MarieandClaude Opus 5.5 78dba05d8f Merge #2516 into fix/desert-leaves-ambient
Both change Animate::Falling: suspicious sand and gravel take the new flag
false (BrushableBlock.animateTick drops dust only; the desert sound is
SandBlock's).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:16 +01:00
Ludovic Marie 0e927e6f11 Merge commit 'refs/cg19/pr/2516' into HEAD 2026-10-27 06:54:01 +01:00
levzzz 2eb96ddb7c Merge remote-tracking branch 'upstream/main' into fix/piston-entity-push 2026-10-27 10:53:49 +05:00
Ludovic MarieandClaude Opus 5.5 97dd6d982a Leave the fox's type and the panda's genes to #2538 and #2551
Those PRs sync the fox's DATA_TYPE_ID with its flags and the panda's genes with
its counters and flags; the same arms here would be unreachable once both land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:53:39 +01:00
levzzz db0a33da3f Merge remote-tracking branch 'upstream/main' into fix/villager-job-site-trades 2026-10-27 10:53:33 +05:00
Ludovic MarieandClaude Opus 5.5 393b8ef7e6 Merge main into fix/player-hurt-entity-trigger
The trident's PLAYER_HURT_ENTITY now keys off main's hurt_at (kept for
Channeling).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:53:29 +01:00
levzzz b5ec5a782f Merge remote-tracking branch 'upstream/main' into fix/thrown-projectile-flight 2026-10-27 10:53:22 +05:00
levzzz 802368fb95 Merge remote-tracking branch 'upstream/main' into fix/container-press-state-lifetime 2026-10-27 10:53:15 +05:00
levzzz 5815560b0b Merge remote-tracking branch 'upstream/main' into fix/sprint-state-persistence 2026-10-27 10:53:01 +05:00
Ludovic MarieandClaude Opus 5.5 15be176380 Merge main into fix/fishing-rod-hooked-trigger
Keeps the hooked trigger with main's item-entity damage (3) and main's
fish_caught per #fishes catch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:52:43 +01:00
Ludovic MarieandClaude Opus 5.5 8905aaf0dd Merge main into fix/armadillo-scutes
Keeps this branch's ShedScute event and main's GiftLoot (with its mob
and the turtle's mob_drops check).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:51:37 +01:00
Ludovic Marie 9ad735b9d7 Merge pull request #2625 from ludovic111/feat/launcher-shortcuts
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:51:21 +01:00
Ludovic MarieandClaude Opus 5.5 4d897de20b Play the rain extinguish sound for every player
Entity.applyEffectsFromBlocks plays playEntityOnFireExtinguishedSound for any
player whose fire the rain puts out: a LAN guest or the server thread's player
(the default single player) heard nothing before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:50:26 +01:00
Ludovic MarieandClaude Opus 5.5 b463f1c80a Waxed hanging signs play their own failed-interaction sound
HangingSignBlockEntity.getSignInteractionFailedSoundEvent is
block.hanging_sign.waxed_interact_fail, not the standing sign's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:50:22 +01:00
Ludovic MarieandClaude Opus 5.5 a8ccf43d2b Merge main into fix/sign-reopen-and-applicators
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:49:57 +01:00
Ludovic MarieandClaude Opus 5.5 6964ee8ece Merge main into fix/stats-movement
Keeps both fall arms: the fall_one_cm statistic and main's landing dust.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:48:18 +01:00
Ludovic Marie 6f19f06634 Merge pull request #2350 from kacpherek/fix/mining-hit-sound-level-events
🤖 Agent: verified against vanilla 26.3 (mining hit sounds now come from the level events, once per hit as MultiPlayerGameMode/LevelRenderer do). I merged main into the branch (one conflict in level_particles.rs, both functions kept). Tested on a tree of main + this PR + #2279: the 33 harnesses its files reach passed, and two real games over LAN (host in-frame and on its server thread) heard 5-7 stone hits per block, with digging, cracks, instant digging, death-while-mining, respawn and hurt checks passing. Thanks @kacpherek!
2026-10-27 06:48:05 +01:00
Ludovic MarieandClaude Opus 5.5 bbc1ffb408 Merge main into fix/invisibility-mob-detection
Keeps both PlayerView methods: visibility_percent and main's has_effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:47:27 +01:00
Ludovic MarieandClaude Opus 5.5 83ee5c303b parity: game-window harnesses run alone on 4-CPU machines
On playhorizon's runner (CPUs 4-7, 4 harness slots, frames drawn on the CPU)
remote_fireworks still failed in #2215's job after #2589 and #2629: a
screenshot took ~1.5 s (58 ticks for two) beside three other harnesses. The
harnesses in parity_hosts.json's new solo list run alone there, after the
others; bigger machines are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:46:49 +01:00
levzzzandminimax-m3.1-flash d966b1e59b Merge kacpherek's PR #2560: the vex holds its item at the small size
`VexModel.translateToHand` draws the held item at 0.55 scale with the
vex's own offset, so the vex's sword is the tiny one vanilla shows.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 10:46:33 +05:00
Ludovic MarieandClaude Opus 5.5 4c78d32e36 Merge main into fix/explosion-beehive-bees
Keeps main's ExplosionSource event and charged_exploders, and this
branch's Species on Explode and hive_emptying_blast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:46:09 +01:00
Ludovic MarieandClaude Opus 5.5 54ab5dcf00 Merge main into fix/save-effects-teams
Main added a mob's hidden_effects (effects given with hideParticles): a saved
mob effect now writes show_particles and show_icon false for them and reads
show_particles back (MobEffectInstance.Details.CODEC), so a reload keeps them
hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:45:26 +01:00
Kacpherek e5e1b74fd8 Draw the enderman portal trail, phantom wing dust and bee nectar drips 2026-10-27 06:44:37 +01:00
Ludovic MarieandClaude Opus 5.5 4516f54e2b Merge main into fix/furnace-wet-sponge-bucket
Keeps main's recipe_used_key with this branch's wet sponge bucket fill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:44:25 +01:00
Ludovic MarieandClaude Opus 5.5 9809bf38b6 Merge main into fix/remote-hurt-tilt
indicateDamage now uses main's knockback direction, which is zero when
the hit has no source position (as in dealDefaultKnockback).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 5ea88533e0 Merge main into feat/panda-states
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:44 +01:00
Ludovic MarieandClaude Opus 5.5 38cd3095e5 Merge main into fix/missing-level-events
Keeps both sets of level-event sounds: this branch's chorus flower and
dripstone-into-cauldron, and main's zombie door and potion splashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:02 +01:00
Ludovic MarieandClaude Opus 5.5 95eb81bfe5 Merge main into fix/sync-mob-variants
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:42:27 +01:00
Ludovic MarieandClaude Opus 5.5 c752c21246 Merge main into fix/remote-entity-events
Keeps both entity-event handlers in remote.rs: this branch's events 20,
6/7/12/13/14/18 and 3, and main's honey slide (53).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:42:22 +01:00
Ludovic MarieandClaude Opus 5.5 594394ab30 Merge main into fix/goat-horns-ram
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:41:31 +01:00
Ludovic MarieandClaude Opus 5.5 82055fb57d Merge main into fix/1893-crystal-explosion
lan_explode now takes main's `small` flag; the explosions a guest's
turn puts off keep it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:41:27 +01:00
Ludovic MarieandClaude Opus 5.5 c3ef0a06e7 Merge main into fix/frog-croak-tongue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:41:15 +01:00
Ludovic Marie bcf2a34a10 Merge #2563
# Conflicts:
#	tools/lan_e2e.py
2026-10-27 06:41:11 +01:00
Ludovic Marie 407053f37c Merge #2561
# Conflicts:
#	crates/horizon-client/src/menu.rs
2026-10-27 06:41:04 +01:00
Ludovic Marie c79b5be2e4 Merge #2564
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:40:59 +01:00
Ludovic MarieandClaude Opus 5.5 531212c812 Merge main into fix/armadillo-roll
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:54 +01:00
Ludovic MarieandClaude Opus 5.5 30027e32a6 Merge main (selectors: the level candidates keep main's is_arrow; /execute on origin and score holders know the crystals and the dragon)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:37 +01:00
Ludovic MarieandClaude Opus 5.5 2e9827a259 Merge main into fix/server-mob-animation-sync-2
The strider's DATA_SUFFOCATING sync is left to #2526, which sends and reads
index 19 already (lan.rs mob_freeze_meta, remote.rs), so both don't send it twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:37 +01:00
Ludovic MarieandClaude Opus 5.5 739d4fe187 Merge main into fix/2095-signal-save
Keeps both: the stop-signal counter from this branch and main's
release_free_memory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:30 +01:00
Ludovic Marie 89225ec081 Merge pull request #2279 from kacpherek/fix/2276-dead-hurt-tilt
🤖 Agent: verified against vanilla 26.3: LivingEntity.baseTick counts hurtTime down for a dead or riding player too, so the hurt tilt no longer restarts every frame and shakes the camera on the server-thread path. Tested on a tree of main + #2350 + this PR: the 33 harnesses its files reach passed, and the LAN hurt, death-while-mining and respawn checks passed with the host in-frame and on its server thread. Thanks @kacpherek!
2026-10-27 06:39:59 +01:00
Ludovic MarieandClaude Opus 5.5 24744aae44 Merge main into fix/1361-lingering-cloud
Resolves potions.rs against main's thrower tracking: splashes keep
their indirectMagic source, and a lingering cloud keeps the thrower too
(AreaEffectCloud.getOwner), so its instant damage names them, else the
cloud.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:39:43 +01:00
Kacpherek 2f6d259615 Put books into a chiseled bookshelf and take them out
Horizon only kept a chiseled bookshelf's saved data, so using it did
nothing. It is now a block entity with six slots and lastInteractedSlot:

- ChiseledBookShelfBlock.useItemOn / useWithoutItem: the slot comes from
  the hit position on the front face (getHitSlot, 3x2). A #bookshelf_books
  item goes into an empty slot (one book, the insert sounds, the used
  statistic), a book comes out to the inventory or the ground, with the
  pickup sounds. The slot_N_occupied states follow
  (ChiseledBookShelfBlockEntity.updateState) and reach a server's clients
  with the block update; the client's prediction ends the click the same way.
- Hoppers and droppers use its container: books only, one to a slot, and the
  occupied state and last slot follow what they moved.
- The comparator reads lastInteractedSlot from the block entity.
2026-10-27 06:38:17 +01:00
Ludovic MarieandClaude Opus 5.5 6c769f38ae Merge main into fix/server-mob-animation-sync
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:58 +01:00
Ludovic Marie 7b11005688 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:37:26 +01:00
Ludovic MarieandClaude Opus 5.5 475f64b79d Merge main into fix/end-gateway-exit
block_entity_nbt moved into horizon-client-core (crate split): save.rs
reads gateway_exit across the crate boundary, so it is public. The
generated block entities now load through decode_block_entity, which
reads the gateway's exit with this PR's decode_end_gateway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:09 +01:00
Ludovic MarieandClaude Opus 5.5 9c3058f756 Merge main into fix/player-pose-dimensions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:06 +01:00
Ludovic Marie 9ca282df1e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/packs.rs
2026-10-27 06:36:44 +01:00
Kacpherek b22c42ea85 Stop jockey spiders attacking and shove hoglin targets like vanilla 2026-10-27 06:34:37 +01:00
Ludovic MarieandClaude Opus 5.5 ef0ea5daad Merge main into fix/drowning-bubbles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:14 +01:00
Ludovic MarieandClaude Opus 5.5 40f8b9600d Shulker canStayAt: only collidable entities block the shell
Level.noCollision(shulker, box) keeps the entities whose
canBeCollidedWith is true for the shulker: living shulkers and adult
happy ghasts (boats and minecarts too, outside the mob world). Players
and other mobs never stop a shulker from opening or landing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:02 +01:00
Ludovic MarieandClaude Opus 5.5 a21cf57a11 Merge main into fix/desert-leaves-ambient
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:01 +01:00
Kacpherek 6c36da40e0 Turn skeletons into strays after standing in powder snow 2026-10-27 06:33:50 +01:00
minimax-m3.1-flash 57213668a0 Add a regression test: an arrow kills a player at half a heart
The health layer spends the last of the player's health and raises the dead
flag, so Game::tick can show the death screen.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 10:33:31 +05:00
Ludovic MarieandClaude Opus 5.5 584fa2a9b5 Merge main into fix/step-sound-discrete
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:33:09 +01:00
Ludovic Marie 6044c6ff88 Merge pull request #2623 from ludovic111/feat/launcher-hide-on-play
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:30:43 +01:00
Ludovic MarieandClaude Opus 5.5 6c09ec2cc8 Save a player effect's icon as shown only when its particles are
MobEffectInstance's show_icon defaults to show_particles: an effect given with
hidden particles hides its icon too, and keeps doing so after a reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:30:27 +01:00
Ludovic MarieandClaude Opus 5.5 4940a24423 Send the fox's type and flags at indices 18 and 19
Fox extends Animal, and AgeableMob has two entries in 26.3 (DATA_BABY_ID 16,
AGE_LOCKED 17): DATA_TYPE_ID is 18 and DATA_FLAGS_ID 19. An int at 17 is the
wrong type for AGE_LOCKED's boolean on a vanilla client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:30:20 +01:00
Ludovic MarieandClaude Opus 5.5 5435eb0f17 Send a cold strider's shiver at index 19
AgeableMob has two entries in 26.3 (DATA_BABY_ID 16, AGE_LOCKED 17), so
Strider's DATA_BOOST_TIME is 18 and DATA_SUFFOCATING 19. A boolean at 18 is
the wrong type for that slot on a vanilla client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:30:07 +01:00
Ludovic MarieandClaude Opus 5.5 765cef8a4e Put the candle's doc comment back on candle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:56 +01:00
Ludovic MarieandClaude Opus 5.5 370444eed0 Dim the End flash's light under a boss bar's world fog
LightmapRenderStateExtractor adds a third of the flash's intensity to the sky
factor while the boss overlay creates world fog (the dragon fight).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:50 +01:00
Kacpherek 29616ad502 Stack toasts in five slots and hide them with F1 2026-10-27 06:29:44 +01:00
Ludovic MarieandClaude Opus 5.5 79fca90886 Tame a pet to the player's real UUID, not an unset placeholder
acting_uuid() falls back to [0, 0, 0, 0] while level.player_uuid is unset (a new
world before its first save); the owner saved then never matches the UUID the
world gets, so the player could no longer command the pet after a reload.
acting_player_uuid() assigns the world's player UUID first, as dropped items do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:42 +01:00
Ludovic MarieandClaude Opus 5.5 ab099b2701 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:28:26 +01:00
Kacpherek f918fc711a Keep the command history between runs and show the restricted chat prompt 2026-10-27 06:28:21 +01:00
Kacpherek cc0f8f8a50 Track a vehicle as far as its widest passenger 2026-10-27 06:27:49 +01:00
Kacpherek c4427d2a14 Size the mount hearts from the server's max health and fix the jump bar 2026-10-27 06:27:34 +01:00
Kacpherek f2db04bd64 Translate the sleep messages and send the respawn point message only when it changes 2026-10-27 06:26:07 +01:00
Ludovic MarieandClaude Opus 5.5 03d8ffe2c6 Sign editor: send the lines as edited, sign or not
AbstractSignEditScreen.setMessage writes messages[line] and the sign;
removed() sends messages. The editor keeps its lines up to date, so a
sign removed while it is open still gets the player's text (review by
rdbt's agent).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:22:28 +01:00
Ludovic MarieandClaude Opus 5.5 6cb5e4954e An occupied horse gives no inventory or feeding either
AbstractHorse.mobInteract (and Horse/ZombieHorse/AbstractChestedHorse)
hand an isVehicle horse to Mob.mobInteract before the inventory, the
feeding and the ride, so the passenger check goes before all three.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:51 +01:00
Ludovic Marie d74d39cb05 Merge pull request #2622 from ludovic111/feat/launcher-console
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:21:26 +01:00
Ludovic MarieandClaude Opus 5.5 2ba33bd146 jumpOutOfFluid: only entities a mob collides with block the hop
EntityGetter.getEntityCollisions keeps the entities that canBeCollidedWith
the mob: among mobs that is a living shulker, not every mob nearby.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:20 +01:00
Ludovic MarieandClaude Opus 5.5 4f6e4e72b1 Keep a client's effects until the server removes them
26.3's client LivingEntity.tickEffects only runs tickClient on each effect;
the server's ClientboundRemoveMobEffectPacket removes one. The join reset
stays. The AGENTS.md wording is the maintainer's to change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:20:42 +01:00
Ludovic MarieandClaude Opus 5.5 73aef0ce8e Merge main into fix/mining-hit-sound-level-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:17:53 +01:00
Kacpherek ff28fd8390 Read a chiseled bookshelf's last slot and a shelf's back mask with a comparator 2026-10-27 06:13:40 +01:00
Ludovic Marie 91e332a5eb Merge pull request #2621 from ludovic111/feat/launcher-import-ui
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:10:22 +01:00
Ludovic Marie a8fe843daa Merge pull request #2620 from ludovic111/feat/launcher-import
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:01:09 +01:00
Kacpherek a19cad22cb A mob jumps out of a fluid only where Entity.isFree allows: no liquid in the box 2026-10-27 05:51:10 +01:00
Ludovic Marie 37d31da543 Merge pull request #2473 from kacpherek/fix/piglin-dance
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Reviewed and approved; verify passed. The two parity failures (server_player_sculk, remote_fireworks) are the load flakes fixed by #2629, which merged after this run started (03:30Z); both pass on the Mac mini at this head. Thanks @kacpherek!
2026-10-27 05:49:18 +01:00
Ludovic Marie c5165a98cf Merge pull request #2618 from ludovic111/feat/launcher-export-ui
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:48:52 +01:00
levzzzandminimax-m3.1-flash ddfa71a46f Merge the End crystal and dragon selectors into the cross-level selectors
Both pull requests reshape Game::select in game/commands.rs, so the
cross-level branch now carries the End crystal and dragon candidates as
well: the crystals and the dragon are listed by level_candidates, and
level_holds knows them, so an unbounded selector finds them in the level
that holds them.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 09:33:56 +05:00
Ludovic Marie e1f7ad93ee Merge pull request #2617 from ludovic111/feat/launcher-export
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:30:25 +01:00
Ludovic Marie 7731b4274a Merge pull request #2616 from ludovic111/feat/launcher-datapack-browser
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:21:08 +01:00
Ludovic Marie 7df621fdb5 Merge pull request #2614 from ludovic111/feat/launcher-mods-per-instance
🤖 Agent: launcher-features-1: base main after #2612 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:19:29 +01:00
Ludovic MarieandClaude Opus 5.5 39c8a2aa0e launcher: Home and the sidebar in French and Russian
The second part of the launcher's translation: Home's banner, rows, crash notice and
Join a friend, the instance row and its actions, the sidebar's headings and instance
states, the partner label, play times and "last played" dates, and the resources line.
Play's label clears its icon in wider languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:13:43 +01:00
asownerytandClaude Opus 5.5 21fd3a6b06 Turn End portal travellers as their teleport does
Checked against the decompiled 26.3 sources: EndPortalBlock's transition
out of the End is Relative.ROTATION plus the respawn data's yaw and pitch,
so a mob or item turns by the world spawn's angles instead of keeping its
own. Into the End it faces west and keeps its pitch, and calculateAbsolute
clamps the pitch to +-90 either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:13:36 +13:00
Ludovic Marie 3eb3d1fc5f Merge pull request #2612 from ludovic111/feat/launcher-datapacks
🤖 Agent: launcher-features-1: base main after #2609 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:10:09 +01:00
Ludovic Marie 3052cc41db Merge pull request #2629 from ludovic111/fix/window-harnesses-under-load
🤖 Agent: playhorizon: remote.sh runs on CPUs 0-3 (CI keeps 4-7); world_reopen and server_player_sculk count ticks; 6 of 6 under load.
2026-10-27 05:10:05 +01:00
Ludovic MarieandClaude Opus 5.5 8beaa57331 launcher: an accent colour and a picture behind Home
Settings > Launcher picks an accent (violet by default, blue, teal, green, amber, red, pink)
and the player's own picture behind Home. An accent turns the violet tokens to its hue and
adjusts their lightness until text on the Play button and the accent on every background read
as well as the violet's. The picture is checked, copied under a name of its hash and shown
cropped under the banner's veils. CLI: settings accent NAME, settings background PATH|default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:07:38 +01:00
Ludovic Marie 43f5388038 Merge pull request #2464 from kacpherek/fix/damage-command-player-source
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/damage` builds `DamageSource(type, cause, by)` with the location or the direct entity's position for the knockback, and the causing mob gets the kill credit. It touched the same code as #2452, so the merged tree uses your `hurt_player_from` for both. Main merged in at f1f587e7c after #2452 landed (world_cmds.rs and creatures.rs: one `hurt_player_from` path that also records the direct entity, as in the union): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 05:02:10 +01:00
SenjinandClaude Opus 5.5 620dfc6e0d HDR: let light sources shine the same under the vignette
The lift's threshold read the frame, which the vignette darkens toward
the screen's edges, so a jack o'lantern lit up fully in the middle of
the screen and only at its eyes and mouth further out. Read it from the
world copy instead; the gain still applies to the frame, so the vignette
darkens highlights as before.

Telling the vignetted world from UI also failed on pixels with a channel
at 0 (a pumpkin's stem): each channel's ratio was compared to their
mean, and a 0 channel's ratio is 0. Hold the channels to one factor, the
ratio of their sums, allowing a step of 8-bit rounding.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:01:43 +01:00
Ludovic MarieandClaude Opus 5.5 b3709b9766 launcher: French and Russian, following the system's language
A string table per language (launcher/lang/<code>.json, English text to its translation)
and tr()/trf() in i18n.rs. The window follows the system's language unless Settings >
Launcher > Language picks one; a missing string shows in English. This first part
translates the top bar, the account switch and the Settings page's account, update and
launcher rows; buttons, segments, section titles and page headers translate through their
widgets. CLI: settings language system|en|fr|ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:58:31 +01:00
Ludovic Marie 08492a3007 Merge pull request #2609 from ludovic111/feat/launcher-update-all
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 04:50:37 +01:00
Ludovic Marie 3d0ef4da75 Merge pull request #2441 from kacpherek/fix/effect-swirls
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.tickEffects` draws the swirls from `DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID`, only for visible effects, and your follow-up keeps `hideParticles` out of both, which matches. Main merged in at 0ab24d080 after #2432 landed (sprint dust and effect swirls both kept in client_ambient.rs and lan.rs, as in the union): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:48:54 +01:00
Ludovic MarieandClaude Opus 5.5 b2199f5a0e launcher: a skin and a cape for each offline name
Settings > Account's Skin... shows the front of an offline name's skin and cape, takes a
skin PNG (64x64 or 64x32) and a cape PNG (64x32 or 22x17), and picks classic or slim arms.
The launcher keeps copies in skins/ and gives the game HORIZON_OFFLINE_SKIN,
HORIZON_OFFLINE_SKIN_MODEL and HORIZON_OFFLINE_CAPE when that name plays. CLI and MCP:
account skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:48:35 +01:00
asownerytandClaude Opus 5.5 dfb28e42d0 Send items and mobs through End portals
The non-player portal path only knew Nether portals, so a cow or a dropped
item standing in an end_portal block never travelled (#1515). It now also
finds End portal blocks (EndPortalBlock.entityInside, its 6/16..12/16
entity-inside shape met along the move), and an entity through one waits as
an arrival with a fixed destination (getPortalDestination): the End's spawn
point above a rebuilt obsidian platform, facing west, or out of the End the
top of the Overworld's shared spawn column. Motion and pitch are kept, the
portal cooldown applies, and saved arrivals keep their End plans.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:46:07 +13:00
Ludovic MarieandClaude Opus 5.5 a2f62b1545 parity_hosts: playhorizon's workers run on CPUs 0-3, 3 harnesses at a time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:42:19 +01:00
Ludovic MarieandClaude Opus 5.5 f1f34edb80 launcher: offline names and a quick switch of who plays
The top bar's account chip opens a menu of every Microsoft account, the
offline names the player keeps and the game's own name; picking one plays
as it. Settings > Account adds and forgets offline names, which reach the
game as --name. CLI and MCP: account offline [NAME], account select
(accounts or offline names), account forget-offline. Share log strips the
offline names too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:37:07 +01:00
Ludovic MarieandClaude Opus 5.5 cb20bb508b remote.sh: runs get CPUs 0-3 by default; benchmarks hold them with --exclusive
playhorizon's CI runner has CPUs 4-7, and remote.sh pinned agents' runs there
too: CPUs 4-7 sat at 100% while 0-3 idled, and window harnesses in pull
requests' parity jobs lost ticks. Runs share a lock per CPU set; an
--exclusive run (a benchmark) holds it alone. world_reopen also waits for
the world to load before its first count, as after each reopen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:31:40 +01:00
Ludovic Marie 4168590221 Merge remote-tracking branch 'origin/main' into fix/window-harnesses-under-load 2026-10-27 04:30:35 +01:00
Ludovic MarieandClaude Opus 5.5 9bc8e12ad1 launcher: GameMode, MangoHud and dedicated GPU switches on Linux
The Launch panel shows, per instance, a switch for each one this computer
has: GameMode when gamemoderun is on the PATH, MangoHud when mangohud is,
and the dedicated graphics card with two cards (DRI_PRIME, or NVIDIA's
render offload variables with its driver). They wrap the game like the
wrapper does, outermost first, and do nothing where the tool is gone.
CLI and MCP: instance launch --gamemode/--mangohud/--dedicated-gpu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:28:42 +01:00
Ludovic MarieandClaude Opus 5.5 2a7b79dfb6 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:26:15 +01:00
Ludovic MarieandClaude Opus 5.5 f1f587e7c2 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:24:30 +01:00
Ludovic Marie 51447edda4 Merge pull request #2604 from ludovic111/feat/launcher-test-seams
🤖 Agent: launcher-features-1 checked this: base main after #2602 merged, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 04:23:13 +01:00
Ludovic Marie 4018fa9559 Merge pull request #2452 from kacpherek/fix/magic-death-message
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `DamageSource.getLocalizedDeathMessage` (the causing entity, else the direct one: the potion or cloud) and `indirectMagic`. I pushed one fix: with no entity at all, vanilla sends the one-argument message and `TranslatableContents.decompose` shows a template it can't fill as written, so I removed the fallback to `magic` / `generic`. Main merged in at e338690bf after #2442 landed (potions.rs: `splash` keeps both the owner and the block): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:23:09 +01:00
Ludovic MarieandClaude Opus 5.5 7115c708e1 launcher: pre-launch command, wrapper and environment per instance
Home's Instance row gets Launch...: a shell command run in the instance's
folder before the game (which starts only if it succeeds), a wrapper the
game runs through, and environment variables, kept in instances.json.
The game stays one process: /bin/sh runs the command and execs the
wrapper and game through "$@" on Unix, cmd /C on Windows. The window's
Play and `play` both use them. CLI and MCP: instance launch (confirm
needed over MCP). The Instance row's actions move under its name, and
editing an instance that shares its name with an import works again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:22:34 +01:00
Ludovic Marie 95e7cd69dd Merge pull request #2602 from ludovic111/feat/launcher-packs-ui
🤖 Agent: launcher-features-1 checked this: base main after #2601 merged, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 04:16:39 +01:00
Ludovic Marie 5a6ae76ec3 Merge pull request #2486 from ludovic111/fix/parity-reap-orphans
🤖 Agent: parity.py reaps the harness processes a failed or timed-out run leaves behind; parity passed on playhorizon.
2026-10-27 04:16:36 +01:00
Ludovic Marie 4a523b5ce8 Merge pull request #2484 from kacpherek/fix/2463-break-block-lagback
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: breaking the block under you no longer sends a position correction the client didn't need. Fixes #2463. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:16:33 +01:00
Ludovic Marie eae5b13fc6 Merge pull request #2483 from kacpherek/fix/held-item-outline
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the block outline uses the shape for the held item (light, scaffolding) and the shulker box's lid, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:39 +01:00
Ludovic Marie 1ad1e957a3 Merge pull request #2477 from kacpherek/fix/polar-bear-attack
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `PolarBear` (its goals, `PolarBearMeleeAttackGoal`'s rearing and warning sound, `PolarBearHurtByTargetGoal`, `PolarBearAttackPlayersGoal`, the stand animation and `DATA_STANDING_ID`). Left for later: the height doesn't grow while standing, the anger isn't saved, and anger at LAN guests isn't tracked. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:35 +01:00
Ludovic Marie b5f1e65162 Merge pull request #2467 from kacpherek/fix/execute-on-origin-leasher
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `execute on origin` (the thrower or shooter) and `on leasher`; your follow-up records the thrower of a guest's drops and the player behind a lead. Left for later: a fence knot leasher isn't an entity in Horizon, so it finds nothing. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:31 +01:00
Ludovic Marie 39910efabf Merge pull request #2466 from kacpherek/fix/autosave-indicator
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Gui.renderSavingIndicator` (the fade in and out around the autosave), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:28 +01:00
Ludovic Marie b0103bd62f Merge pull request #2465 from kacpherek/fix/baby-walk-position
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the baby leg animation uses the interpolated `walkAnimation` position, not the tick value, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:24 +01:00
Ludovic Marie 44eb7008f1 Merge pull request #2461 from kacpherek/fix/guest-text-commands
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/tellraw`, `/title` and `/playsound` reach a server's players as their packets, and your follow-up plays a remote sound in the packet's category (`handleSoundEvent`). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:11 +01:00
Ludovic Marie 179e410923 Merge pull request #2460 from kacpherek/fix/bonus-chest
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `BonusChestFeature.place` (shuffled columns, the chest with the `spawn_bonus_chest` loot table, a torch on each side that survives). I pushed one fix: `setInitialSpawn` passes the level random and the feature sets the blocks with flags 2 (`UPDATE_CLIENTS`). Left for later: it is skipped when the spawn chunk is not lit yet. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:07 +01:00
Ludovic Marie b0a6e63ce7 Merge pull request #2459 from kacpherek/fix/jigsaw-distance-padding-defaults
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `JigsawStructure`'s codec defaults (`max_distance_from_center` 80, the default `dimension_padding`), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:04 +01:00
Ludovic Marie 4517d75a65 Merge pull request #2458 from kacpherek/fix/drowned-thrown-trident
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ThrownTrident` (8 damage, `dealtDamage`, the 0.99 water inertia, the hit and ground sounds, `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`) and the drowned's `TridentAttackGoal`. I pushed one fix: the damage goes through `EnchantmentHelper.modifyDamage` of the trident, so Impaling adds only for `#sensitive_to_impaling` (its `minecraft:damage` requirement), not for every mob that swims. Fixes #2447. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:00 +01:00
Ludovic Marie 4b042eae28 Merge pull request #2457 from kacpherek/fix/template-block-entity-nbt
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `StructureTemplate.placeInWorld` loads each block's template NBT into its block entity, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:11:57 +01:00
Ludovic Marie 30c6a4ac7b Merge pull request #2456 from kacpherek/fix/particle-count-zero
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ClientPacketListener.handleParticleEvent` with count 0 makes one particle moving at max speed times the delta. I pushed one fix: both are floats in the packet, multiplied as floats before the widening. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:46 +01:00
Ludovic Marie ce73764585 Merge pull request #2455 from kacpherek/fix/horse-tail-swing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AbstractHorse.aiStep`'s 1-in-200 `moveTail` and its 8-tick `tailCounter`, with the model's tail swing, match. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:41 +01:00
Ludovic Marie 59d0b45349 Merge pull request #2454 from kacpherek/fix/score-text-components
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ScoreContents` resolution in `/tellraw` and `/title`; your follow-up gives armor stands, frames, paintings, boats, carts and others their UUID name, as `getScoreboardName` does. Left for later: the `*` holder resolves to the acting player, not to each recipient. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:37 +01:00
Ludovic Marie 7373ee8da5 Merge pull request #2453 from kacpherek/fix/entity-shadows-option
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the Entity Shadows option gates `EntityRenderDispatcher`'s shadow pass, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:33 +01:00
Ludovic Marie a6af0d647f Merge pull request #2445 from kacpherek/fix/2384-sort-cursor-item
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the client's `AbstractContainerMenu.clicked` predicts the sort and tweak clicks like any other, so the last sorted item stays under the cursor until the server answers. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:29 +01:00
Ludovic MarieandClaude Opus 5.5 8b87d128e6 parity: world_reopen and server_player_sculk count ticks, not request round trips
On playhorizon (frames drawn on the CPU, control requests answered slowly)
world_reopen read the minecarts before the mineshaft's last one arrived (3
then 4 on every reopen), and the sculk walk polled the sensor with separate
requests that missed its 30-tick active window. The minecart list is read
once it stops changing; both clients sample the sensor every tick from one
ctl script, alongside the walk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:08:32 +01:00
Ludovic Marie 8a2c7eee4a Merge pull request #2444 from kacpherek/fix/enchanted-hit
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.attack` sends the enchanted hit (animate action 2, `MAGIC_CRITICAL_HIT`) when the weapon's enchantments add damage, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:17 +01:00
Ludovic Marie 8a572c9239 Merge pull request #2443 from kacpherek/fix/mob-death-poof
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.tickDeath` sends entity event 60 at 20 ticks and the client's `makePoofParticles` matches. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:12 +01:00
Ludovic Marie 6ea9f9580c Merge pull request #2440 from kacpherek/fix/bundle-selected-item
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BundleItem` / `ClientBundleTooltip` mouse-wheel selection (`ServerboundSelectBundleItemPacket`, cleared when the cursor leaves the slot, now on a server's client too). Left for later: the selected item's name line in the tooltip. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:09 +01:00
Ludovic Marie e9bcd10869 Merge pull request #2435 from kacpherek/fix/landing-dust
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.checkFallDamage` sends the landing `block` particles (`150 * scale` of them, scaled by the fall distance, at speed 0.15) where the fall ends, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:05 +01:00
Ludovic Marie 3bd504c6c4 Merge pull request #2433 from kacpherek/fix/llama-spit-burst
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LlamaSpit.recreateFromPacket` adds seven spit particles at 0.4 + 0.1 i of its movement, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:01 +01:00
Ludovic Marie a0343bceab Merge pull request #2431 from kacpherek/fix/explosion-emitter-size
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerExplosion.isSmall` (radius under 2, or the blocks kept) picks the small particle, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:41 +01:00
Ludovic Marie 25ad1db6d3 Merge pull request #2430 from kacpherek/fix/item-tooltip-lines
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ItemStack.addDetailsToTooltip`: the order here (instrument, bees, container, banner patterns, written book, charged projectiles with their indented details, fireworks, potion with `When Applied:`, jukebox, trim, stored and plain enchantments, dyed, lore, attributes, unbreakable, block state, spawner, then the advanced durability, id and component count) is vanilla's, and the crossbow lines match `ChargedProjectiles.addProjectileTooltip`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:38 +01:00
Ludovic Marie 65cc4c6f6b Merge pull request #2429 from kacpherek/fix/lan-player-pose-flags
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `Entity.DATA_SHARED_FLAGS_ID` bits (on fire, crouching, swimming, invisible, glowing, fall flying) and the pose for LAN players; your follow-up also sends the crouching pose under a low ceiling, which matches `Player.updatePlayerPose`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:34 +01:00
Ludovic Marie 8892f0fabf Merge pull request #2427 from kacpherek/fix/creaking-heart-explosion
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `CreakingHeartBlock` / `CreakingHeartBlockEntity.removeProtector` on an explosion kills the bound creaking. Left for later: no XP for a heart broken by a player's TNT (Horizon doesn't track the TNT's owner yet). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:31 +01:00
Ludovic Marie d6fc0f6e43 Merge pull request #2424 from kacpherek/fix/note-block-particle
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `NoteBlock.triggerEvent` adds the note particle 1.2 above the block with note / 24 as its colour, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:27 +01:00
Ludovic MarieandClaude Opus 5.5 82c8398c6b launcher: desktop shortcut that plays an instance
Home's Instance row gets Shortcut / Remove shortcut, and the CLI and MCP
instance shortcut [instance] [--remove]. The shortcut runs
horizon-launcher play --instance <id>: a .desktop file on Linux (and in
the applications menu), a script app with Horizon's icon on macOS, and a
Shell Link written from Microsoft's published MS-SHLLINK layout on
Windows. HORIZON_LAUNCHER_DESKTOP points it at a scratch folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:02:20 +01:00
Ludovic Marie 517cbe76bd Merge pull request #2423 from kacpherek/fix/entity-broadcast-range
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ChunkMap.TrackedEntity.getEffectiveRange` ends in `scaledRange`, which applies `entity-broadcast-range-percentage`, as done here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:02:02 +01:00
Ludovic Marie 236371d06d Merge pull request #2422 from kacpherek/fix/honey-block-slide
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `HoneyBlock` (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects` with its 1-in-5 sound and particle rolls, entity event 53). Your follow-up runs it for a server's player too, with the sound to the others and event 53 to everyone, which is how vanilla splits it. Left for later: mobs don't slide yet, and there is no `honey_block_slide` criterion. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:58 +01:00
Ludovic Marie 06c2371360 Merge pull request #2421 from kacpherek/fix/bee-wither-rose-eyeblossom-contact
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WitherRoseBlock`'s effect (wither for 40 ticks on the mobs inside it) and the eyeblossom's poison on bees. Since then you added `MobEffectInstance.update`'s rule (a stronger effect replaces, an equal one keeps the longer duration, a weaker one changes nothing), which matches. Left for later: a weaker, longer effect is not kept hidden to resume afterwards (`hiddenEffect`). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:55 +01:00
Ludovic Marie b3a1f66c35 Merge pull request #2419 from kacpherek/fix/animate-tick-extras
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's particle providers and `animateTick`s: honey drips (`DripParticle`), the poplar and azalea leaf particles, falling dust under gravity blocks and the campfire food smoke match vanilla's rates, sprites and velocities. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:51 +01:00
Ludovic Marie cca3b735cc Merge pull request #2462 from kacpherek/fix/mob-block-layers
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SnowGolem.DATA_PUMPKIN_ID` and `MushroomCow.DATA_TYPE` with their layers (`SnowGolemHeadLayer`, `MushroomCowMushroomLayer`), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:48 +01:00
Ludovic MarieandClaude Opus 5.5 e338690bf9 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:58:56 +01:00
Ludovic MarieandClaude Opus 5.5 0ab24d0805 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:58:55 +01:00
Ludovic Marie dfa907d28e Merge pull request #2601 from ludovic111/feat/launcher-packs-core
🤖 Agent: launcher-features-1 checked this: base main, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 03:57:27 +01:00
Ludovic Marie d9dc1c866b Merge pull request #2442 from kacpherek/fix/splash-potion-burst
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LevelEvent` 2002 / 2007 (`LevelEventHandler`: the item crumbs, then the coloured spell or instant effect particles) and the 1053 / 1054 break sounds. It conflicted with main in `game.rs` (the zombie door sounds 1019 / 1021 landed there), so I merged main in, keeping both lists. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:56:51 +01:00
Ludovic Marie ec8d5f1e06 Merge pull request #2432 from kacpherek/fix/sprint-dust
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Entity.canSpawnSprintParticle` and `spawnSprintParticle` (the block under `getOnPosLegacy`, clamped into it, fences and walls taken from the supporting block) match. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:56:48 +01:00
Ludovic MarieandClaude Opus 5.5 cde33a8e42 launcher: minimise or hide the window while the game runs
Settings > While playing: Stay (the default), Minimise, or Hide (macOS).
The window goes away when the game starts and comes back to the front
when it closes, with the run's play time, errors and log. CLI and MCP:
settings while-playing stay|minimize|hide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:51:54 +01:00
Ludovic MarieandClaude Opus 5.5 0ccf3e110b launcher: live game console with search, follow, copy and share log
Home's Console shows the game's output of this run as it comes, with a
search, Follow, Copy and Open logs folder. Share log uploads it to mclo.gs
only after the player confirms, with the home folder, user name and
account names taken out first, and can delete the paste again. CLI:
share-log and unshare-log (MCP tools, the second needs confirm).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:44:53 +01:00
Ludovic MarieandClaude Opus 5.5 36c18fd8ea Merge main into fix branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:38:30 +01:00
Ludovic MarieandClaude Opus 5.5 b8b353c92e launcher: import a file dropped anywhere on the window
A file dropped on the window goes where it belongs:
- a shared instance becomes a new instance, which gets selected on Home;
- a Java modpack goes to the Modpacks import;
- a resource or shader pack goes into the selected instance;
- a data pack points to the world rows.

A note in the corner says what happened, until it is closed. The Packs
section and the world rows still take their own drops, and now stop the
drop there so it isn't imported twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 e28bcba3d3 launcher: import a shared instance, or route any dropped file
`horizon_launcher::import` works out what a file is from its contents:
- a shared instance: a .mrpack needing no mod loader (as `instance
  export` writes) or a game-folder zip;
- a Java modpack: a .mrpack with a loader, or a CurseForge zip;
- a resource or shader pack;
- a data pack.

A shared instance becomes a new instance named after the pack, with
"(2)" if the name is taken. Its overrides/ (or the zip's entries) are
copied in, but only the files an instance keeps, and only paths that
stay inside its folder. Its .mrpack files are downloaded from Modrinth
and checked against their SHA-512. If anything fails, the half-made
folder is removed.

CLI and MCP: `horizon-launcher instance import <file> [--instance <i>]`
does what dropping the file on the window will do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:31:52 +01:00
Ludovic MarieandClaude Opus 5.5 4482ff63f3 launcher: Export… on Home's Instance row
The Instance row gets Export…, which opens a panel to pick .mrpack or
.zip and switch on the worlds to include. The file goes to exports/ in
the launcher's data folder under a free name, and the panel offers Show
in folder. The panel closes when another instance is picked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:25:19 +01:00
Ludovic MarieandClaude Opus 5.5 a9e5214ae4 launcher: export an instance as a .mrpack or a zip
`horizon-launcher instance export [<instance>] [--format mrpack|zip]
[--world <w>]... [--out <file>]` (and its MCP tool) writes what makes an
instance, or a port: options.txt, horizon-*.txt, servers.dat, resource
and shader packs, horizon-mods/ and config/, plus the worlds named. It
never includes logs, screenshots, backups, caches or account files. A
world leaves out playerdata/, stats/ and advancements/, and its level.dat
is written without the player's UUID.

A .mrpack follows Modrinth's published format: modrinth.index.json
lists the resource packs the browser installed from Modrinth that are
unchanged, by their download with SHA-1 and SHA-512 asked from
Modrinth's API. The rest goes under overrides/. An instance with nothing
to share is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:24:53 +01:00
Ludovic MarieandClaude Opus 5.5 2a7420651e launcher: find data packs on Modrinth and install one into a world
The Mods page gains a Data packs tab: Modrinth's data packs for 26.3,
each marked Partly with what Horizon runs of a data pack today
(functions, function tags, predicates, item modifiers). Install puts the
project's data pack file (not its mod build), checksum checked, into the
datapacks/ of a world picked on the tab, and says what of that zip runs
and what isn't read yet. A pack with nothing Horizon runs (worldgen
only) is refused. The world's list on Home shows it.

CLI and MCP: `mods search --kind datapack`, `mods install <pack> --world
<world>`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:15:49 +01:00
Silas LunsfordandGLM 5.3-Flash c3a12bf792 client: retune the entity-shadow depth bias to kill its rim flicker
The shadow quads lie exactly on block tops like vanilla's, but terrain
and shadows are separate draws: their fp32 depth interpolations disagree
by a few dozen ulps of the fragment depth, and at a constant bias of
16 ulps the rim re-rolled the fight with the block top as the camera
drifted a subpixel - the visible speckle. Subpixel yaw glides at three
camera pitches (70, 45, 20 down) measured ~88/~60/~26 strong flip
pixels a frame with the shadow, all on the rim, none without it.
A constant bias of 128 ulps covers the spread at any resolution while
staying far below what the 0.001 a model's feet sit higher amounts to,
so shadows never bleed onto the feet.

Co-Authored-By: GLM 5.3-Flash <[email protected]>
2026-10-26 21:11:44 -05:00
FusselTVandClaude Opus 5.5 d601715126 Streaming: the last finished ring is lit against decorated neighbours
A chunk was lit only once all eight neighbours were finished, so the
outermost finished ring of a loaded area (a /forceload square, a /pregen
band's edge, a player's last ring) stayed unlit: saved as FULL with no
light at all, and with no generation animals, which wait for light.
Vanilla's LIGHT step needs its neighbours only at INITIALIZE_LIGHT
(ChunkPyramid), so its last FULL ring is lit against decorated chunks.

A light job now reads a neighbour that will not finish (past the area)
from its decorated proto chunk (ChunkGenerator::light_view). A chunk
saved unlit is saved again once lit, and one whose light changes when a
neighbour finishes later is saved again. `execute if loaded` needs the
chunk lit, as vanilla's needs it ENTITY_TICKING.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:08:53 +01:00
Ludovic MarieandClaude Opus 5.5 9cb2779608 launcher: install mods and packs into the selected instance
The Mods page and `mods` commands installed everything into the default
game folder whichever instance was picked. Mods, resource packs and
shader packs now go into the selected instance's folder, and the
Installed list, updates, Remove and the mod switches are that
instance's; picking another instance reads its list. The page says which
instance it installs into when there are several. `mods list|install|
update|remove|enable|disable` take `--instance` (default: the selected
one). Java mods' checks stay shared in the default folder for the
search results.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:02:09 +01:00
socksproxandDevin e70ead5bfe Multiplayer parity: four client/server movement and use divergences
- A remote client no longer tramples farmland or turtle eggs locally:
  BlockLandingWorld.is_server_level/is_client_side now reflect the
  player's client_side flag, as vanilla's fallOn gates trampling and
  entity events on `level instanceof ServerLevel`. The server's block
  change and teleport drive the change, so a player no longer ends up
  inside the grown block (phasing through when farmland turns to dirt).
- key.swapOffhand, key.drop and a mount's inventory key on a server now
  wait for the tick's input phase instead of sending a packet straight
  from the window event, like the click queue: no action packet leaks
  between CLIENT_TICK_END and a later pong.
- remote_use_item treats a refused consume like vanilla's
  InteractionResult.FAIL: a full player who right-clicks food no longer
  starts a use the server will refuse (the eat/noslow desync), the off
  hand gets its turn, and no use cooldown starts.
- The host's "moved too quickly" check subtracts the guest's
  server-simulated velocity like vanilla's expectedDist term, so a
  fast legitimate arrival (e.g. landing with elytra) isn't flagged.

Co-Authored-By: Devin <[email protected]>
2026-10-27 02:51:55 +01:00
Ludovic MarieandClaude Opus 5.5 aa6fdac501 Launcher: data packs into a world
datapacks.rs lists a world's datapacks/ (zips and folders with
pack.mcmeta, as the game discovers them; off when datapacks.json lists them
disabled), adds a data pack zip under a free name (refusing resource packs:
no data/ folder) and removes one to the trash, forgetting it in
datapacks.json. The game turns a new pack on when the world opens.

Home: each world gets "Data packs", which opens its packs under the row
with Add data pack… and Remove; a zip dropped on a world goes into it.
CLI: `worlds datapack-list|datapack-add|datapack-remove <world>`;
datapack-remove needs confirm over MCP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:49:42 +01:00
Ludovic MarieandClaude Opus 5.5 cac63dedca Launcher: Update all for installed packs
The Mods page's Installed list gets "Update all (N)" when newer versions
were found: every pack with one is updated in turn on a thread (each
download checked as an install is), with "1 of 2: …" progress, and one that
fails is named without stopping the others. The CLI already does this with
`mods update` and no pack name (MCP: mods_update).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:42:09 +01:00
DanielGodriNetoandClaude 9ae37fcb50 feat(threads): measure thread CPU time on Windows from cycle counts
thread_cpu() returned None on Windows. Implement it from thread cycle counts,
inside horizon-threads so horizon-client keeps forbid(unsafe_code).

Co-Authored-By: Claude <[email protected]>
2026-10-26 22:40:34 -03:00
socksproxandDevin b39c4b8dd4 mobs: jumpOutOfFluid wants a dry, entity-free box
Entity.isFree is noCollision(this, box) && !containsAnyLiquid(box); the
check only looked at block collisions, so a mob pressed against a wall by
water flow hopped out where vanilla refuses, and it ignored other
collidable entities entirely.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:36:53 +01:00
Ludovic MarieandClaude Opus 5.5 3c257bb3e6 AGENTS.md: launcher test runs set HORIZON_LAUNCHER_TRASH and HORIZON_LAUNCHER_CLIPBOARD
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:31 +01:00
Ludovic MarieandClaude Opus 5.5 2ef155348f Launcher: keep test runs away from the real trash and clipboard
desktop_seams.rs holds the launcher's two hard-to-undo desktop effects.
HORIZON_LAUNCHER_TRASH=<folder> makes Delete (worlds, screenshots, packs)
move into that folder instead of the system's trash, and
HORIZON_LAUNCHER_CLIPBOARD=<file> makes every Copy in the window write into
that file instead of the clipboard. Test runs set both inside their scratch
folders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:32:14 +01:00
DanielGodriNetoandClaude 8866e618b5 perf(light): cache touched sections during relight
Relight now runs over a Region that clones each touched section's Arc once,
copies its light on first write and writes back only sections whose light
changed. The increase loop reads state and light in one lookup. Output is
identical; a randomized border test checks it against a full relight.

Co-Authored-By: Claude <[email protected]>
2026-10-26 22:31:59 -03:00
socksproxandDevin 7732ce986f Drop a client's remembered effects when it joins a world
A rejoining player kept the previous session's effect list, so the HUD
could show effects the server no longer had (nothing applied in combat,
no icon was earned). Vanilla's new LocalPlayer starts empty and gets its
effects from UpdateMobEffect; match that, and let the client-side tick
drop effects whose duration ran out, as its removeIf does.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:31:11 +01:00
Ludovic MarieandClaude Opus 5.5 e5c3d7cff7 Launcher: Packs section on Home, with drag and drop
Home lists the selected instance's resource and shader packs with a switch
each (disabled while the game runs: it rewrites options.txt itself), Remove
after a confirmation, and Add pack… or a .zip/.hshader dropped on the
section. switch() now takes any id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Ludovic MarieandClaude Opus 5.5 ddaa514f3a Launcher: resource and shader packs per instance (library and CLI)
packs.rs lists an instance's resource packs (resourcepacks/, a .zip or a
folder with pack.mcmeta) and shader packs (shaderpacks/: Horizon's own and
the shaders/ format, as the game's Shader Packs page lists them), and turns
them on or off the game's own way: resourcePacks in options.txt (a pack
turned on goes on top) and pickedShaderPack in horizon-graphics.txt (one at
a time); every other line of both files stays. add() copies a dropped file
into the right folder under a free name; remove() turns it off and moves it
to the trash.

CLI: `packs [list|add|on|off|remove] [--kind] [--instance]`; `packs remove`
needs confirm over MCP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Ludovic MarieandClaude Opus 5.5 c352c80c96 Books: save a plain page as a string, as ComponentSerialization does
Pages kept as components for their click events wrote a plain text page as
{text:"p"}; vanilla's codec writes a component with no style or siblings
as its string (the hotbar harness).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:11:32 +01:00
Devin 42ef865c34 Give shulkers vanilla placement, push and look rules
A shulker used to check only that its cell was air and the support
block had a full collision box. canStayAt now mirrors vanilla: a moving
piston in the shell's own cell does not force a teleport, the support's
face toward the shell must be sturdy, and the fully opened shell's box
must be clear of blocks and entities. teleportSomewhere likewise checks
the target cell with noCollision and refuses faces tagged
minecraft:shulker_does_not_teleport_to, emits level event 2016 (whose
particles now render), and forces a position sync.

The shell easing toward its peek now pushes collidable entities out of
the swept box (onPeekAmountChange / MoverType.SHULKER) through a new
MobEvent::Shift, so standing on a shulker lifts you instead of wedging
you inside the lid.

ShulkerLookControl is ported too: the head yaw is computed in the
attach face's plane (new LookKind::Shulker and look_yaw) instead of the
generic world-yaw, and hurtByTarget now ignores damage from other
shulkers as toIgnoreDamage does.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:11:05 +01:00
socksproxandDevin (SWE-2 High) e8891849a4 AGENTS.md: name posters by GitHub handle, never a real name
The "names its own person" rule was ambiguous about which identity an
agent should attribute a post to; handles are the public identity.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:43:34 +01:00
socksproxandDevin ee0af20e16 Cap the view scale's render distance by the server's
streaming.radius reverts to the client setting in apply_settings, so
an options change on a server with a lower view distance raised every
entity's render range past vanilla's. min(render_distance,
remote.view_distance) is Options.getEffectiveRenderDistance.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:27:41 +01:00
levzzzandminimax-m3.1-flash 9ab0e2b6a8 Cover the slime block's push and bounce in the LAN harness
A piston pushing a slime block moves the body in front of it and bounces
it: the block's swept collision shape pushes the body out of the way, and
the slime block sets the body's velocity along the push axis
(`PistonMovingBlockEntity.moveCollidedEntities`, the `causeBounce` branch).
The new `piston-slime` LAN case checks both for the host and the guest, with
a plain block as the control: a plain block only pushes the body 0.81
blocks, the slime block pushes and bounces it 2.5.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:08:40 +05:00
minimax-m3.1-flash 498de72da2 Frog: walk at vanilla's speed
Vanilla's frog uses a SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true), so
a walking frog covers 0.1 blocks a tick (its movement speed attribute is 1.0
and the controller's outside-water speed is 0.1). Horizon gave every generic
animal the plain ground controller, which drives the mob at the attribute
itself: a frog walked ten times too fast, jittered, teleported and flew
(#2213).

The regression test fails without the change.
2026-10-27 05:07:45 +05:00
levzzz 04672c9613 Merge remote-tracking branch 'upstream/main' into fix/selector-end-crystals 2026-10-27 05:04:30 +05:00
Kacpherek 76bebec833 Stop vexes suffocating inside blocks 2026-10-27 01:01:53 +01:00
levzzzandminimax-m3.1-flash fa4f38f824 commands: an unbounded selector reaches the other loaded levels
`Game::select` gathered its candidates from the level in place only, so
`@e[tag=...]` never found an entity in another dimension the server
still holds: vanilla's `EntitySelector` searches every loaded level
unless the selector carries a spatial constraint.

The selector now searches each loaded level in turn, and the commands
that act on the entity it found (`/data get entity`, `/kill`, `/tp`)
act on it in the level that holds it. The experience orbs and the
projectiles are named by their entity id, like the other entities, so
the entity a command acts on is the one the selector found.

Fixes #1524

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:01:07 +05:00
Joansitoh 9cebcc8d7f Launcher: a paste keeps the spaces at its ends
Pasting " --world Test" after an argument lost its leading space and
joined the two arguments. The first line of the clipboard is inserted as
it is.
2026-10-27 00:59:55 +01:00
levzzz 14dac749e3 Merge remote-tracking branch 'upstream/main' into fix/thrown-projectile-flight 2026-10-27 04:50:53 +05:00
levzzz 10d0132a53 Merge remote-tracking branch 'upstream/main' into fix/mining-dig-stop-check 2026-10-27 04:50:40 +05:00
levzzz 209fa169cd Merge remote-tracking branch 'upstream/main' into fix/villager-job-site-trades 2026-10-27 04:50:35 +05:00
levzzz 17fa6e826c Merge remote-tracking branch 'upstream/main' into fix/potion-contents-in-the-creative-inventory 2026-10-27 04:50:23 +05:00
levzzz 66bec37b55 Merge remote-tracking branch 'upstream/main' into fix/container-press-state-lifetime 2026-10-27 04:50:18 +05:00
levzzz 0fe747ad51 Merge remote-tracking branch 'upstream/main' into fix/sprint-state-persistence 2026-10-27 04:48:57 +05:00
minimax-m3.1-flash 5be7c02d0c Cover the mining dig stop in the LAN harness (#1340, #1908)
A block the client breaks at once (a creative break, a dead bush) sends no STOP or ABORT, so the server has to forget the dig itself. Without it the server keeps mining the emptied cell: the block put back there is destroyed again (#1340) and the mining sound keeps playing at it (#1908, the cell's sound group is stone).

The new dig_stop check breaks a stone block in creative and a dead bush in survival for the host and the guest, then puts the block back and waits: it must stay, and no mining sound may play.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:22 +05:00
minimax-m3.1-flash e11471cda0 Run the LAN harness headless
HORIZON_E2E_HEADLESS launches the games with --offscreen --no-pause, so the suite runs on a machine with no focused window: without it the games pause on the focus loss and wait_ready never completes. macOS keeps its offscreen launch.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:22 +05:00
levzzzandminimax-m3.1-flash 731a35612a Lock the benchmark's load gate: a scene waits for the chunks
The benchmark's readiness rule waits for the resident chunk count to reach
the view's target before "steady" means loaded, so a scene that starts with
an empty view (the integrated server has not sent its first chunk yet) is
measured after the world is really there. The rule had no test, so a change
could silently drop it. This adds one: a fake control that keeps the counts
steady and the queue empty for a second and a half, and only then lets the
chunks arrive, must not end the wait before they do. Without the gate the
wait ends after the quiet second and the test fails.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:01 +05:00
FusselTVandClaude Opus 5.5 b1450f1ef2 Mobs: foxes, rabbits, goats and horses keep their state
finalizeSpawn picks a fox's type (red or snow), a rabbit's type, a goat's
screaming flag and horns and a horse's max health, but the save and the
load did not keep them: a snow fox came back red, every rabbit brown, a
goat that had lost a horn grew it back, and a new horse was saved with
the type's health (53) above the max health it rolled.

- Fox, Rabbit and Goat.addAdditionalSaveData: Type, RabbitType,
  IsScreamingGoat, HasLeftHorn and HasRightHorn are saved from the model
  and read back (an unknown rabbit type is brown, horns default to
  present, as vanilla reads them).
- The fields Horizon does not model (a fox's Trusted, Sleeping, Sitting,
  Crouching, a rabbit's MoreCarrotTicks, a horse's EatingHaystack and
  Bred) keep a loaded value or get vanilla's default.
- A loaded horse or llama gets its health clamped to its max health, as
  LivingEntity.setHealth does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:17 +01:00
levzzzandminimax-m3.1-flash e184ab7a42 A villager that loses its job site offers its new profession's trades
The reported sequence in #1639: a villager works at a bench, the bench is
destroyed and it takes another bench's job, and then it does not open a
trading menu.

`Villager.setVillagerData` drops the offers (`offers = null`) when the
profession changes, so the next `AbstractVillager.getOffers` rolls the new
profession's trade set. The brain reaches that through `set_profession`,
which now also marks the offers as not made (#2009); this test covers the
whole reported sequence, from the lost job site to the new profession's
trades, so a villager that keeps the old profession's offers marked as made
cannot pass it.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:35:26 +05:00
Kacpherek a934dac2bd Save and load the difficulty through level.dat's difficulty_settings 2026-10-27 00:32:30 +01:00
levzzzandminimax-m3.1-flash 6384234e37 Stop a piston push at the world border
`move_pushed` resolved the pushed movement with `SceneCollisions`, which
supplies no world-border collision, so a piston next to the border could push
a player through it even though ordinary movement stops at the border.
`Entity.move` collects the border's walls in
`collectCollidersIgnoringWorldBorder` whenever
`WorldBorder.isInsideCloseToBorder` holds, and `CollisionGetter.borderCollision`
adds `WorldBorder.getCollisionShape` to them. The push now resolves through
`EntityCollisions::with_border`, which applies that same rule to the pushed
body's own x and z, so a body pushed into the border's wall stops there.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:32:16 +05:00
Carlos Nunez 3caf28c4d6 Merge remote-tracking branch 'upstream/main' into tmp/merge-2298
# Conflicts:
#	tools/lan_e2e.py
2026-10-26 19:27:09 -04:00
Kacpherek 87d577e566 Check rain at the player's and arrow's position
Arrows now ask whether it rains at their spot (biome precipitation and
blocks above, Level.isRainingAt) instead of just the sky light, and a
Riptide trident needs rain at the player's position. Rain also puts out
a burning player (Entity.baseTick), in the window and for guests on the
server thread.
2026-10-27 00:20:08 +01:00
Kacpherek b5794fa5b3 Check rain at the mob's position, not the global weather
Water sensitive mobs, endermen, dolphins, wolves, sun burn and the happy
ghast's healing now ask the rain at their own spot (Entity.isInRain,
Level.precipitationAt) instead of the world raining flag. Rain also puts
out a burning mob, and striders are hurt by water and rain.
2026-10-27 00:17:04 +01:00
levzzzandminimax-m3.1-flash 4c9075a072 Keep the thrown-item reproduction for #1762
A server's client ticks a projectile itself between the entity tracker's
updates, as vanilla's Entity no-op interpolation handler does; before #2086 it
only eased towards the server's position, so a thrown egg, ender pearl or wind
charge hung in the air for half a second. The client tick is now a named
function with unit tests for that flight, and the parity harness keeps the
reproduction the issue asked for: a connected client throws each of them and
samples their position twice as often as the tracker's interval. The LAN
harness can also run headless now (HORIZON_E2E_HEADLESS), so the games render
offscreen and never pause on the focus loss on a machine with no focused
window.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:08:31 +05:00
levzzzandminimax-m3.1-flash 7ae20a5b29 A potion from the creative inventory keeps its contents in the hotbar
#1832 reported that a potion taken from the creative inventory and put in
the hotbar turned into the uncraftable potion. The hand-over keeps the
stack as it is on `main` (#1796 and #1874), so this adds the test that
keeps it that way: the item tab's stack is the real one, the creative
screen hands it over unchanged, and the hotbar slot holds the brew.

Fixes #1832

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:07:36 +05:00
Kacpherek 4e82a62489 Vexes hold their sword at the small size Java uses 2026-10-27 00:01:54 +01:00
levzzzandminimax-m3.1-flash 67b6e75c0b The container screen's press state does not survive the screen
AbstractContainerScreen keeps `isQuickCrafting` and the drag it belongs to
on the screen, so every screen starts with its own state. Horizon keeps
both on `Game`, and #2052 only dropped them when the pointer was grabbed
again, so a press whose release never arrived (the pointer left the
window) kept the drag running on the next screen: `Clicks::press` answers
`None` for every later press, so the Shift quick move and the clicks on
the inventory went dead.

The press state now goes with the pointer: `Game::capture` drops it
whichever way the pointer changes hands (a screen opens or closes, the
window loses focus), and `Clicks::cancel_drag` names the drop.

Fixes #1737

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:01:42 +05:00
Louis NordbøandClaude Opus 5.5 64cd55cf74 Finish a use on the server's USE_ITEM_COMPLETE, and eat on the client too
On a server client (single player's server thread, LAN guests, dedicated
servers) the window ended a consumable's use on its own timer and never ate
the food: its FoodData waited for the server's next ClientboundSetHealth.
With the button still held, the next startUseItem found the food bar below
20 and began another bite, so the eating animation went on after the bar
was full (#2471).

Vanilla's client never completes a use by itself: LivingEntity.updateUsingItem
completes it only on the server, and ServerPlayer.completeUsingItem first
sends the player entity event 9 (USE_ITEM_COMPLETE). Player.handleEntityEvent(9)
then runs completeUsingItem on the client, where Consumable.onConsume calls
the FoodProperties listener on both sides: the client's FoodData eats at once.

- The server sends event 9 to the acting guest when a consumable, a potion or
  a spyglass use completes. Like the guest's packets, the events are queued
  during their turn (kept across nested turns) and sent with their entity id.
- The client keeps using until that event, then shows the last crumbs, eats
  the food (the next ClientboundSetHealth replaces it) and starts the item's
  use_cooldown, which ItemStack.use only starts at once for an item with no
  use time.
- What vanilla's client checks so that a use can't outlive the server's:
  MultiPlayerGameMode.useItem passes on an item cooling down (chorus fruit
  held through its cooldown), LivingEntity.updatingUsingItem stops when the
  hand no longer holds the item the use began with (ItemStack.isSameItem),
  a respawn is a new player with no use, and Consumable.startConsuming
  starts none for a consumable with no use time.
- ItemCooldowns tick where Player.tick ticks them: after LivingEntity.tick's
  use on the server and after Minecraft.handleKeybinds on the client. Both
  sides ticked them first, so a guest's entry ended a tick later on the
  server than on their client, whose held retry was refused and never ended.

lan_e2e's new eat_full check eats melon slices up to a full bar with the
button still held: before, a new bite had started (use ticks 26, 2, 9, 15);
now nothing is in use. Held on two chorus fruits, both are eaten across the
cooldown. consume-release holds its completed use a little longer, as the
use now ends with the server's event.

Fixes #2471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:59:03 +01:00
levzzzandminimax-m3.1-flash 8909e0510c commands: selectors find the End crystals and the Ender Dragon
Horizon's command targets enumerated the ordinary entity collections
only, so `@e[type=end_crystal]` and `@e[type=ender_dragon]` matched
nothing: the End crystals and the dragon live in their own lists, and
neither carried an entity id a command could name.

The crystals and the dragon now take an id from the entity counter when
they are created, as EndCrystal's and EnderDragon's constructors do; the
selector offers them as candidates; `/data get entity` reads them and
`/kill` removes them the way `Entity.kill` does. `/summon
minecraft:end_crystal` creates one, as vanilla does.

Fixes #1531

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 03:55:39 +05:00
Louis NordbøandClaude Opus 5.5 5b5ebfcce3 Walk turtles at their own pace so their feet stop flailing (#1776)
Turtles used the default MoveControl, which sets the speed straight to
speedModifier * movement speed (0.25 on a stroll). Vanilla gives them
TurtleMoveControl: updateSpeed halves the speed every tick on land (never
below 0.06), and in water lifts the turtle 0.005 and cuts the speed away
from home (0.08) or as a baby (0.06); tick then eases the speed an eighth
of the way to the wanted one, faces the wanted point with the body, lifts
towards it and never jumps. On land that settles at 0.08375, so a
strolling turtle covered about nine times the ground per tick that
vanilla's does (the input is speed squared). TurtleModel swings the legs
by 8 * walkSpeed at walkPos * 5, so the walk animation fed by that motion
spun the feet erratically.

This only fixes the move control. Swimming still differs from vanilla:
Turtle.travelInWater (moveRelative(0.1F), 0.9 drag, sinking 0.005 without
a target unless heading home within 20 blocks of it), TurtlePathNavigation
and the turtle goal set are not ported yet; turtles keep the generic
fluid travel and goals for now.

Vanilla: Turtle$TurtleMoveControl.tick / updateSpeed, Turtle.<init>,
TurtleModel.setupAnim, LivingEntity.updateWalkAnimation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:50:24 +01:00
Kacpherek 9ada76e00a Restore the lam-alef madda and hamza ligature rows in FORMS 2026-10-26 23:41:13 +01:00
Kacpherek 2c894783aa Bubble columns lift and drag mobs, items and ender pearls 2026-10-26 23:37:14 +01:00
Kacpherek 9266e0c7c9 Mobs are pushed by flowing water and lava 2026-10-26 23:34:03 +01:00
Kacpherek a68ee8009f Save and load a mob's active effects, keep a player's hidden particles
A mob's effects were never written to its NBT and never read back, so
they were gone after a reload. LivingEntity.addAdditionalSaveData and
readAdditionalSaveData store them as active_effects.

A player's effect saved show_particles as always on and read it back
from show_icon, so an effect given with hideParticles came back with
particles.
2026-10-26 23:33:46 +01:00
levzzzandminimax-m3.1-flash ef0714a99a Lock in the sprint state that outlives the sprint key
Sprint is a state, not a held key: LocalPlayer.aiStep starts it with the
sprint key and stops it only on a vanilla stop condition, so releasing the
key keeps it while forward is held. The server-side overwrite that rewrote
the flag from the held keys is gone, so the underwater swim, the sprint
jump and the flight speed follow the state on main. This adds the
end-to-end regression tests that were missing for those three paths: the
swim speed with the key released, the jump impulse with the key released,
and the flight speed that follows the sprint state and not the sprint key.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 03:22:24 +05:00
Kacpherek 909f15b909 Count the warden tendril and heart animations down again 2026-10-26 23:22:03 +01:00
Kacpherek da57350af5 Pandas: genes, sit, roll, lie on back, sneeze and unhappy states, synced to clients
A panda now has vanilla's states: it sits to eat bamboo or cake it picks up or is
given, a lazy one lies on its back, a playful one or a cub rolls over, a cub
sneezes, and a panda that is fed with no bamboo around sulks. A worried panda
sits and shivers in a thunderstorm. The genes show in its coat, pass to its
young, and are saved. The flags, counters and genes go to clients as entity
data, and the model, the item in its mouth, the crumbs and the sneeze are drawn
from them, in game and in a LAN guest's window.
2026-10-26 23:19:43 +01:00
Kacpherek bf64f868d1 Send a mob's body and saddle equipment to the single player window
The body slot (horse armour, llama carpet, harness, nautilus armour) was sent as the head slot and the client ignored slots 6 and 7. Send the body item as slot 6 and apply slots 6 and 7 to the fields the renderer reads.
2026-10-26 23:16:08 +01:00
masa-masa-0314andZCode 66607c9eab Items: glint enchanted armour on armour stands
ArmorStandRenderer draws worn armour through the same
HumanoidArmorLayer as a living entity, so its pieces should use the
armour glint when ItemStack.hasFoil() is true. The stand's equipment
loop now checks the stack's enchantment_glint_override component, then
the item's default override, then its enchantments component (vanilla's
Item.isFoil), and draws both the armour and its trim layer with overlay
13. The existing ARMOR_GLINT path supplies the model UV and samples
enchanted_glint_armor through ARMOR_ENTITY_GLINT_TEXTURING.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 06:14:36 +08:00
Kacpherek 0d496fdc8f Save and load the variant of axolotls, rabbits, parrots, salmon and tropical fish 2026-10-26 23:14:31 +01:00
Kacpherek 4cad82b4a9 Draw the variant of rabbits, parrots and axolotls, salmon sizes and the baby goat, polar bear, ocelot, turtle and armadillo
RabbitRenderer, ParrotRenderer, AxolotlRenderer and SalmonRenderer pick
their texture or model from the variant the mob already carries, and the
baby models and textures of the other AgeableMobRenderer species are used.
A baby zombie villager uses its own texture.
2026-10-26 23:14:31 +01:00
Kacpherek 8a5d7bb78d Leave the extended Arabic letters unshaped, as ICU does 2026-10-26 23:12:44 +01:00
Kacpherek 400d9e6115 Show goats' lost horns and let them lower their head and ram 2026-10-26 23:11:56 +01:00
Kacpherek 140958fa58 Sync the variants and looks of server mobs to the window
The window's mobs are built only from SetEntityData, but mob_meta sent
only some species' data and remote_mob_data decoded even less. Send and
read the colour, type and state entries that the renderers draw from.
2026-10-26 23:07:25 +01:00
Kacpherek 04b0b9aed5 Let frogs croak, shoot their tongue at small slimes and idle-swim 2026-10-26 23:06:30 +01:00
Kacpherek 5e27fd3421 Use the mob level event that main already has 2026-10-26 23:04:35 +01:00
Kacpherek e544924504 Draw entity outlines for the Glowing effect
Glowing entities are drawn as team-coloured silhouettes into an outline
target with no depth test, then vanilla's entity_outline chain (sobel,
box blur across and up) is blended over the frame after the hand.
Mobs, players, item entities and projectiles get outlines. The server
sends shared flag 6 for mobs and items, and the remote client reads it.
2026-10-26 23:03:39 +01:00
Kacpherek 29754b857d Merge origin/main into feat/fox-states 2026-10-26 23:02:27 +01:00
Kacpherek 8144385ce6 Give foxes their stalk, pounce, faceplant, shelter and perch states
The fox flags were only ever set for sleeping. Add the goals that drive
the rest of Fox.DATA_FLAGS_ID and show them on the client:

- StalkPreyGoal, FoxPounceGoal, FaceplantGoal: creep up on a chicken or
  rabbit, crouch, leap with the flight tilt and bite, or end up with the
  nose in snow (with its block crumbs).
- SeekShelterGoal and PerchAndSearchGoal.
- The fox's own conditions on the shared goals (melee, look at player) and
  clearStates on float, breed and follow parent; FoxMoveControl and
  FoxLookControl waits.
- The head tilt and crouch amount ease on the client from the synched
  flags, and the model, body tilt and sleeping, snow and baby textures
  follow them.
2026-10-26 23:02:03 +01:00
Kacpherek 11e1ff12d0 Let armadillos roll up when scared, peek out and take less damage as a ball 2026-10-26 22:59:54 +01:00
Murillo DeolinoandCodex faeff96cf9 Add Wayland launcher window controls
Co-Authored-By: Codex <[email protected]>
2026-10-26 18:59:32 -03:00
Kacpherek 26aef75d7c Shape and reorder right-to-left text before drawing it 2026-10-26 22:58:52 +01:00
Kacpherek 25c53cc803 Server-thread clients animate ravager, hoglin, iron golem, creaking, vex, allay, axolotl, guardian, copper golem and strider
Builds on the warden, sniffer, breeze, camel and shulker sync. Each of these mobs
now sends its synched data and entity events, and the client runs the animation
steps vanilla runs on the client.
2026-10-26 22:56:31 +01:00
masa-masa-0314andZCode 43e407562f Items: animate the armour glint from enchanted_glint_armor
Enchanted worn armour lost its animation: mobs/render.rs gave every
armour vertex [0, 0] for glint_uv, while the entity shader samples the
glint at that coordinate for overlay 7, so the armour showed a single
texel instead of the scrolling pattern.

Armour layers now carry overlay 13 (ARMOR_GLINT) and the layer's model
UV. entity.wgsl applies ARMOR_ENTITY_GLINT_TEXTURING (scale 0.16) and
samples misc/enchanted_glint_armor through the same repeating, linear
GlintSampler as items, matching ARMOR_CUTOUT_NO_CULL_GLINT. The scroll
clock (millis * glintSpeed * 8 modulo the offsets' common period) rides
in globals.time.w, so armour and items move together.

Addresses the review comment on #2296.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 05:54:10 +08:00
Kacpherek 348054d26e Draw F3+B hitboxes for every entity 2026-10-26 22:52:16 +01:00
Kacpherek c05dc8af22 Use vanilla's head turn limits for guardian, shulker, ravager, camel and sitting wolf, and draw a parrot in water as flying 2026-10-26 22:49:24 +01:00
Kacpherek a45d0de43e Server-thread clients animate warden, sniffer, breeze, camel and shulker
A client of a server (single player's window included) never got the
animation state these mobs compute on the server: no pose, no sniffer
state, no shulker peek or wall face, no camel sit or dash, no warden
tendril, attack or sonic boom events.

Send Entity.DATA_POSE and the species' synched data from mob_meta,
broadcast the warden's entity events 4, 61 and 62, read both in the
remote mob code, and run the client animation steps in
mobs::sync::client_tick.
2026-10-26 22:48:27 +01:00
Kacpherek 688733132d Freeze mobs in powder snow and shake frozen mobs and cold striders 2026-10-26 22:47:27 +01:00
Kacpherek 9dd6933dab Size players' boxes by pose, including sleeping 2026-10-26 22:46:36 +01:00
Kacpherek 87b533c5a9 Draw the guardian's swimming bubbles and the bubble trail along its beam 2026-10-26 22:46:15 +01:00
Kacpherek 7f6e47b02e Hide player arrows and markers on a map in an item frame 2026-10-26 22:45:48 +01:00
Kacpherek 7fdd57ad1e Play the level-up sound for each level a gain crosses 2026-10-26 22:45:14 +01:00
Kacpherek f6db8b1f56 Scroll and add the energy swirl of charged creepers and powered withers 2026-10-26 22:44:16 +01:00
Kacpherek dca96dd1df Fix turtle step timing and camel sand step sounds 2026-10-26 22:43:50 +01:00
Kacpherek e0e484e44b Fix the baby zombie villager, happy ghast and player hurt voice pitch 2026-10-26 22:43:18 +01:00
Kacpherek b3e1542e09 Follow vanilla's block outline rules: high contrast option, F1 and adventure 2026-10-26 22:43:10 +01:00
Kacpherek 57e8de0007 Send the drowning entity event (67) and draw the bubbles on clients 2026-10-26 22:42:59 +01:00
Kacpherek 861e7279e0 Drip water from wet sponges and dust from suspicious sand and gravel
Wet sponges now drip water from their sides and bottom like
WetSpongeBlock.animateTick, and suspicious sand and gravel now shed
falling dust in their map colour when nothing is below them.
2026-10-26 22:41:57 +01:00
Kacpherek 7673d38ed7 Play the amethyst chime when walking on amethyst blocks 2026-10-26 22:41:23 +01:00
Kacpherek 426b3c83cf Play the sand idle and poplar leaves ambient sounds 2026-10-26 22:40:47 +01:00
Kacpherek f2856845f4 Stop End flashes lighting the scene when Hide Lightning Flashes is on
Vanilla only adds the End flash to the sky light factor while the option is off. Horizon added it every time.
2026-10-26 22:40:27 +01:00
Kacpherek 1d93086e33 Ease the night vision pulse with the partial tick
The last 10 seconds of Night Vision pulse in brightness. Vanilla reads the pulse at (duration - partial tick), so it eases between ticks. Horizon used the whole tick count, so the brightness stepped once per tick.
2026-10-26 22:39:50 +01:00
Kacpherek 00e15171d5 Draw the smoke trail behind wither skulls and fireballs, and the wither's head smoke on a client 2026-10-26 22:38:21 +01:00
Kacpherek 960b8aabad Villagers in an ongoing raid splash: send entity event 42 2026-10-26 22:38:18 +01:00
Kacpherek 3f1181bab4 Play player step sounds while crawling under a low ceiling 2026-10-26 22:37:48 +01:00
Kacpherek 1596b84299 Create New World: the Game Rules button opens the rules screen and the choices are saved with the world 2026-10-26 22:37:24 +01:00
Kacpherek 190cf4e0c2 Changing Chunk Builder sets the graphics preset to Custom 2026-10-26 22:30:03 +01:00
Kacpherek cb488cf515 Rain splashes follow the Weather Radius and the Weather option
Splashes always used a radius of 10 blocks; vanilla uses the Weather Radius
option. The Horizon Weather toggle now also stops the splashes and the rain
sound, not only the falling rain.
2026-10-26 22:29:18 +01:00
Kacpherek a146754b0b Make the Particles option thin every particle, explosion debris and rain splash
The Particles option only changed two effects. Follow ClientLevel.doAddParticle
and calculateParticleLevel in the one add path: Decreased drops a third, Minimal
drops all but the always-visible and limiter-overriding particles. Explosion
block debris is thrown only on All, and rain splashes halve on Decreased and
stop on Minimal.
2026-10-26 22:28:10 +01:00
levzzz bafd010865 Merge remote-tracking branch 'upstream/main' into fix/piston-entity-push 2026-10-27 02:22:04 +05:00
FusselTVandClaude Opus 5.5 a55c825467 Minecarts save HasTicked and Fire 0
AbstractMinecart saves Entity.firstTick as HasTicked, and a cart's fire
stays 0: Entity.move sets it to -getFireImmuneTicks(), which is 0 for
everything but players. A mineshaft's chest minecart was saved without
HasTicked and with Fire -1; vanilla's generated chunk saves it with
HasTicked 1 and Fire 0, and a cart that has ticked with HasTicked 0 and
Fire 0.

The mineshaft writes both as the proto chunk saves a new cart, and a
cart keeps first_tick from its save until its first tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
FusselTVandClaude Opus 5.5 6c716f457b Mobs: farm animals and frogs keep their variant; sound variants are saved
finalizeSpawn picks the variant of a pig, cow, chicken or frog and the
sound variant of a pig, cow, chicken, cat or wolf, but the save wrote
neither for these: pigs, cows, chickens and frogs came back temperate
after a reload, and every cat and wolf was saved with the classic sounds
whatever it rolled. Chickens also lost their egg timer.

Every mob with a variant component now saves and loads `variant`
through finalize::species_variant_component / variant_index (the cat and
wolf code used its own copy). The sound variant is kept as an index into
the species' registry (None is the default, classic) and is saved and
read back; Chicken also saves `EggLayTime`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
Kacpherek 8f78fdeb7b Keep leash knot ids per level and clear remote knots with the other remote objects 2026-10-26 22:08:35 +01:00
Kacpherek 6977659843 Open the command block screen on a server's client and send its edits 2026-10-26 22:06:50 +01:00
Kacpherek d2a8cf3bdf Keep a hurt pet's attacker as its player instead of swapping in the owner 2026-10-26 22:05:02 +01:00
Kacpherek ed9fdb5208 Point an explosion's hurt tilt at the blast 2026-10-26 22:04:40 +01:00
Ludovic MarieandClaude Opus 5.5 3f0e70882c Death messages: no fallback for a message with no entity behind it
DamageSource.getLocalizedDeathMessage sends the one-argument message, and
TranslatableContents.decompose shows a template it cannot fill as written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:03:42 +01:00
Ludovic MarieandClaude Opus 5.5 2929bb6dba open_url: accept the http and https schemes in any case
Util.parseAndValidateUntrustedUri lowercases the scheme before checking it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:03:42 +01:00
Kacpherek 6120c8c0ad Attack the block when a guest's START_DESTROY_BLOCK arrives 2026-10-26 22:03:17 +01:00
Ludovic MarieandClaude Opus 5.5 54ae097faa Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:00:46 +01:00
Ludovic MarieandClaude Opus 5.5 1b0937abff Piglin celebration: the dance roll and wasHurtBy as in PiglinAi
wantsToDance draws from RandomSource.createThreadLocalInstance(gameTime),
not the piglin's random, and wasHurtBy ignores another piglin's hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:00:05 +01:00
Kacpherek 53ea3e7224 Play the rain sound for this client only 2026-10-26 22:00:04 +01:00
Ludovic MarieandClaude Opus 5.5 34f57dbcac Trident: Impaling through the enchantment's damage effect
ThrownTrident.onHitEntity runs EnchantmentHelper.modifyDamage on the
trident, so Impaling adds to the 8 for the entity types in
#sensitive_to_impaling (its minecraft:damage requirement), not for every
mob that swims or breathes water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:31 +01:00
Kacpherek 462787aee1 Keep the Infested event when a mob is hit by a player or another mob 2026-10-26 21:58:25 +01:00
Ludovic MarieandClaude Opus 5.5 83b544f04f Bonus chest: draw from the level random and set blocks with UPDATE_CLIENTS
MinecraftServer.setInitialSpawn passes level.getRandom() to
BonusChestFeature.place, which sets the chest and torches with flags 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:57:31 +01:00
Carlos NunezandClaude Sonnet 5.5 b90f9ec1ef Make an End gateway's block entity the source of its exit
The side file kept an entry for a gateway after Horizon created its exit,
so removing the exit with /data remove block ... exit_portal, or placing
a different gateway there, sent the player to the old exit again.

Once the gateway's chunk is loaded, gateway_job moves an entry the side
file still holds into the block entity (unless the block entity has an
exit of its own) and drops the entry. set_gateway_exit writes only the
block entity when the chunk is loaded, and leaves an entry in the side
file only when the chunk is already unloaded.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 16:57:07 -04:00
Ludovic MarieandClaude Opus 5.5 1258c2473b Multiply the particle speed and delta as floats, as handleParticleEvent does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:54:53 +01:00
Kacpherek 77fe538e09 Let only the owner command a tame wolf or cat, and make pets follow their owner
Sitting, standing, dyeing and healing a cat or wolf now check the saved
owner. The follow, teleport and owner-hurt goals use the owner, not the
nearest player, and a new tame wolf or cat is owned by whoever tamed it.
2026-10-26 21:48:05 +01:00
Kacpherek d8b8246622 Light redstone ore under dropped items too 2026-10-26 21:46:31 +01:00
Kacpherek 35a02c79a8 Cats also skip chests a copper golem has open 2026-10-26 21:46:18 +01:00
Kacpherek 3bb6e40304 Clear a bundle's selection when the cursor leaves it on a server's client too 2026-10-26 21:46:13 +01:00
Kacpherek db985f717c Spectator crosshair also checks the other half of a double chest 2026-10-26 21:46:04 +01:00
Kacpherek e41c602359 Keep picking up a banner next to a raider even when its path was cached as unreachable 2026-10-26 21:46:00 +01:00
Kacpherek eab8d0f911 Slide on honey for the server's player and show the slide effects to multiplayer clients 2026-10-26 21:45:02 +01:00
Kacpherek daee960ba2 Send an armor stand's default pose when it is reset 2026-10-26 21:43:21 +01:00
Kacpherek b33ae69938 Find the End podium level from its rim so a broken centre column is fine 2026-10-26 21:43:08 +01:00
Kacpherek d7fece5825 Keep hideParticles of mob effects: no swirls drawn or sent for them 2026-10-26 21:42:52 +01:00
Kacpherek a529be8915 Keep translucent item rendering with enchantment glint 2026-10-26 21:40:21 +01:00
Kacpherek bab4251715 Give armor stands, frames, paintings, boats, carts and other entities a scoreboard name 2026-10-26 21:37:47 +01:00
Kacpherek 9eb913affe Show the crouching pose of a guest who is not holding sneak 2026-10-26 21:37:33 +01:00
Kacpherek c808fae288 Keep a stronger active effect when a weaker one is added 2026-10-26 21:37:30 +01:00
951b88a3c2 Read an End gateway's exit from its block entity
Horizon had no End gateway block entity: game/end.rs kept each gateway's
exit in its own side file and gateway_job only looked there, so a
gateway set with /setblock end_gateway{exit_portal:[I;..],ExactTeleport:1b}
(or the exit worldgen writes) was ignored, and /data get block said the
block was not a block entity.

TheEndGatewayBlockEntity stores Age, exit_portal and ExactTeleport.
BlockEntity::EndGateway now holds them with vanilla's NBT and the
spawnable-bounds check. gateway_job reads the block entity's exit first
and keeps the side file and the 512-block End-spawn rule as fallbacks,
so older saves and gateways without data behave as before. The exits
Horizon creates are stored in the block entity as well, a new exit keeps
the gateway's own ExactTeleport (setExitPosition), and a command that
only changes a block entity's NBT now marks its chunk modified so the
change is saved.

Fixes #1517.

Implementation by Codex (GPT-6.1 Sol), reviewed and extended by Claude.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 16:36:53 -04:00
Kacpherek d75ef5e4f7 Draw translucent item quads without depth writes so water shows through glass items 2026-10-26 21:31:51 +01:00
Kacpherek 414a2b82a8 Do not rebuild the End exit podium once the fight has its location 2026-10-26 21:30:57 +01:00
Kacpherek da736d56d6 Lingering clouds skip mobs none of their effects can affect 2026-10-26 21:30:42 +01:00
Kacpherek 9958fe0e49 Light redstone ore when an entity stands on it 2026-10-26 21:29:26 +01:00
Kacpherek 65c60ab996 Play remote sounds in the category the packet names 2026-10-26 21:23:38 +01:00
Kacpherek abb4e7b5cd Record the thrower of guest drops and the player behind a lead 2026-10-26 21:21:40 +01:00
Kacpherek f4a235e027 Record a witch potion source only when it deals damage 2026-10-26 21:20:45 +01:00
Kacpherek cdafcd756f Open links and page links in written books 2026-10-26 21:18:05 +01:00
Kacpherek 3fe301aaa1 Record the dragon's attacker before the crystal blast and only for applied damage 2026-10-26 21:17:18 +01:00
levzzzandCodex dca6ebba0e Merge upstream main into piston branch
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-27 01:15:46 +05:00
Ludovic MarieandClaude Opus 5.5 3755bb7d77 parity: stop harnesses on SIGTERM, and reap orphaned harness JVMs
Nine vanilla Java servers from harness runs had been orphaned on the Mac mini for 2 to 5 days. Each harness runs in
its own session (killpg on a timeout), so when a CI job was cancelled, its harnesses and their servers outlived
parity.py.
- parity.py: SIGTERM and SIGHUP exit through the finally blocks, which now stop the local executor's harnesses
  too, not only the remote hosts'. The worker (--worker) stops its harnesses the same way.
- runner-gc.sh (every 6 h from launchd): stops Java processes whose parent is gone (launchd, init or
  systemd --user) and whose command line names a runner's checkout or a worktree's .local/, once ORPHAN_HOURS
  (default 3) old.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:10:41 +01:00
Kacpherek 94748162bc Handle a dig packet before the move queued behind it
The client breaks the block under its feet and falls in the same tick. The
hub checked that move against the block that was still there and sent the
player back. Moves queued behind a dig packet now wait for the next tick,
after the break.
2026-10-26 21:08:59 +01:00
Kacpherek 06b046d8fc Light, scaffolding and shulker box outlines follow the held item and the lid 2026-10-26 21:07:17 +01:00
levzzzandCodex 6af81b600f Push entities with moving pistons
Move players, mobs and common objects when piston block entities advance, applying collision and the per-tick piston limit. Exercise host, guest, inventory-open and mob pushes in both single-player paths, and compare the push geometry with vanilla.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-27 01:01:31 +05:00
Kacpherek c85f6f2e39 Give polar bears their attack AI and rearing pose
Polar bears only panicked and strolled. They now follow vanilla's goals: melee attack that rears up and growls before each strike, hurt-by and cub-defending targets, anger at the player, and fox hunting. A hurt cub calls the adults. The standing flag is synched and the client animates it.
2026-10-26 20:50:13 +01:00
Kacpherek 851789830a Open chat links from open_url click events 2026-10-26 20:44:17 +01:00
Kacpherek 36c06d6133 Let a piglin celebrate a killed target and dance over a hoglin 2026-10-26 20:41:38 +01:00
Kacpherek ffc343aadd Make execute on origin and leasher find their entity 2026-10-26 20:37:39 +01:00
Kacpherek 4cf9ffd847 Draw the autosave indicator 2026-10-26 20:36:40 +01:00
Kacpherek e23fcb1040 Scale a baby's walk position when it is read, as vanilla does 2026-10-26 20:35:46 +01:00
Kacpherek 27b81d9a8f Give /damage on a player its source, knockback and kill credit 2026-10-26 20:35:42 +01:00
Kacpherek 7b3595b8f2 Draw the snow golem's pumpkin and the mooshroom's mushrooms 2026-10-26 20:34:18 +01:00
Kacpherek ae3737b1c2 Mod API: render.set-far-terrain, and the official Far View mod
A mod can now turn on the game's distant terrain (LOD) with
render.set-far-terrain: a distance of 64 to 1024 chunks and Far Detail's
Reduced. The game builds and draws the tiles as it does for the LOD
Distance setting, which wins while it is on. One far terrain shows at a
time, the first loaded mod's. API level 4.

mods/official/far-view is the official mod on it: Distance (256 chunks by
default) and Far detail on its settings page, kept in its storage.
2026-10-26 20:34:08 +01:00
Kacpherek c417556834 Send /tellraw, /title and /playsound to LAN guests 2026-10-26 20:33:36 +01:00
Kacpherek bf62ccdabc Place the bonus chest in new worlds 2026-10-26 20:33:16 +01:00
Kacpherek 41d8b9e48a Jigsaw structures: default max distance vertical and padding top like vanilla 2026-10-26 20:32:20 +01:00
Kacpherek 0e658e3e62 Make drowned throw real tridents instead of arrows 2026-10-26 20:31:21 +01:00
Kacpherek 041ecb7b7a Structures: load the template's block entity NBT into every container and sign 2026-10-26 20:31:16 +01:00
Kacpherek 41ca489d4e Move /particle count 0 particles at speed times delta 2026-10-26 20:31:14 +01:00
Kacpherek 82d2b61a4a Swing a horse's tail now and then 2026-10-26 20:30:33 +01:00
Kacpherek f9b8b40a7d Show score components in /tellraw and /title 2026-10-26 20:30:13 +01:00
Kacpherek 08cd05ed6e Make the Entity Shadows option turn shadows off 2026-10-26 20:30:05 +01:00
Kacpherek f312aae7db Name the potion, cloud or thrower in magic death messages 2026-10-26 20:29:22 +01:00
masa-masa-0314andZCode Agent 317f30427e Superflat: keep an empty layer list on Re-Create
FlatSettings::from_tag rejected layers: [], so a world with every layer removed fell back to the classic bedrock/dirt/grass settings when re-created. An empty saved list is a valid void flat generator; a non-empty list whose entries all fail to parse is still rejected.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-27 03:25:05 +08:00
kohad1ckandZCode Agent 91f1f839ab Superflat: the Customize screen edits layers and presets
Create New World greyed out Customize for a Superflat world because Horizon
had no preset editor. Bring vanilla's CreateFlatWorldScreen and
PresetFlatWorldScreen:

- the layer list (top first), Remove Layer and the layer heights;
- the preset chooser with the nine visible flat_world_preset entries, their
  display items and names;
- the share box and Select, parsing layers;biome and validating block and
  biome ids against the prepared registries;
- Done keeps the layers in WorldOptions, Cancel and Escape discard them.

world_loading writes them with write_world_gen_settings_with, so a new
world's world_gen_settings keeps the custom generator; Re-Create reads them
back for the editor.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-27 03:25:05 +08:00
Kacpherek 591b7b74da Predict the sort and tweak clicks on a server like an ordinary click 2026-10-26 20:15:10 +01:00
Kacpherek d78238f765 Show the enchanted hit when a melee weapon's enchantments add damage
Player.attack shows magic crit particles whenever the enchantments added
damage. Horizon showed only the critical hit, for the window's player and
for a guest's attack.
2026-10-26 20:14:32 +01:00
Kacpherek 303281ed09 Puff a poof where a dead mob's body goes
A mob removes its body twenty ticks after dying and tells everyone with
entity event 60, which draws twenty poofs about it. Horizon removed dead
mobs silently.
2026-10-26 20:12:28 +01:00
masa-masa-0314andZCode 1c6c6c0b97 Items: sample the glint from its own repeating texture
Vanilla's GlintSampler repeats enchanted_glint_item per pixel, and the
GLINT transform's scale 8 spans about 8 repeats across an item. Folding
the UV per vertex into the shared atlas could only interpolate between
the four corners, so the pattern rendered as one wide diagonal band
instead of the texture's fine weave. The glint now binds as its own
128x128 texture with a repeat, linear, mip-free sampler (the texture's
mcmeta: clamp false, blur true) in the HUD and entity pipelines, and
`Glint` passes the unwrapped `GLINT_TEXTURING` UV through.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:11:47 +08:00
Kacpherek 18f8a6868b Burst a breaking potion into item crumbs and coloured spell particles
A thrown potion or experience bottle that breaks sends level events 2002 or
2007 with its colour and the splash sound (1053, 1054). Horizon spawned only
eight terrain crumbs, on the server's own side, so a server player saw
nothing.
2026-10-26 20:09:23 +01:00
Kacpherek 43d1f4b801 Draw the swirls of potion effects around players and mobs
A client draws the particles of an entity's visible effects itself, from
the synched effect particle list (LivingEntity.tickEffects). Horizon drew
none and sent no such list: now the window's own player, a server's mobs
and players, and the mobs and guests of a game in the frame all swirl.
2026-10-26 20:06:26 +01:00
masa-masa-0314andZCode a1761dcb65 Lightning: move the bolt offset to vertex attribute 7
The retained bolt offset and the entity vertex's new glint UV were both
declared at attribute location 6, and the lightning pipeline builds its
vertex buffers from the entity layout plus the bolt-offset instance
buffer, so Device::create_render_pipeline rejected the location clash
and the client crashed at startup.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:04:05 +08:00
masa-masa-0314andZCode cf6b91a710 Items: draw the enchantment glint from enchanted_glint_item
Horizon invented the glint: the GUI drew sine bands in screen space and
the entity shader layered the same over held and dropped items. Vanilla
26.3 samples `misc/enchanted_glint_item` through `GLINT_TEXTURING`
(`item.vsh`/`item.fsh`, `TextureTransform.setupGlintTexturing`), so
items now carry the real texture:

- `ItemQuad.foil_uv` keeps the model UV the glint samples; `Glint` turns
  it into the atlas UV with the 26.3 transform (translation(-o0, o1) *
  rotateZ(PI/18) * scale) and `WorldSink` gives foil quads overlay 7 and
  the per-corner glint UV.
- The GUI glint is a separate additive HUD pass masked by the item's
  texture alpha (`item.fsh`'s ALPHA_CUTOUT 0.1 and GLINT path).
- `entity.wgsl` samples the glint at overlay 7/8 like vanilla's
  `color.rgb += glintColor.rgb * glintColor.rgb`.
- The Accessibility options Glint Speed and Glint Strength work: both
  sliders persist to `options.txt` (they were hidden placeholders), the
  GUI pass takes them per draw, and `Frame.glint_alpha` feeds the entity
  shader every frame instead of whatever the last HUD item set.
- GUI items use `GLINT_TEXTURING`'s scale 8, world items
  `ENTITY_GLINT_TEXTURING`'s scale 0.5.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:04:05 +08:00
Kacpherek a08f3eb2fa Select a bundle's item with the mouse wheel 2026-10-26 20:02:36 +01:00
Kacpherek 96b538d48f Throw up landing dust where a hard fall lands
A landing that hurts sends the block's crumbs from the server
(LivingEntity.checkFallDamage). Horizon built the event but dropped it, for
the window's player and for every server player.
2026-10-26 19:59:59 +01:00
Kacpherek 29eae3a1af Throw a burst of spit particles when a llama spits 2026-10-26 19:58:51 +01:00
Kacpherek 4efa49ae82 Raise sprint dust under running players
A sprinting player kicks up dust of the block under their feet every tick
(Entity.spawnSprintParticle). Horizon drew none, for the window's player
or for the other players it sees.
2026-10-26 19:58:22 +01:00
Kacpherek 85db9a23a9 Use the small explosion particle when an explosion keeps blocks 2026-10-26 19:57:54 +01:00
Kacpherek 00cb813dc8 Show the missing item tooltip lines in vanilla order
Custom names are italic. Unbreakable, dyed, written book author and
generation, music disc songs, goat horn instruments, loaded crossbow
projectiles, hive bees and honey, the potion "When Applied" block and
enchantment attribute modifiers (Efficiency V gives +26 Mining Efficiency)
now appear, and advanced tooltips (F3+H) end with the item id and the
component count. Attribute lines use the attribute's sentiment colour.
2026-10-26 19:57:18 +01:00
Kacpherek 9e848670db Send swimming, gliding, fire, invisible and glowing for LAN players 2026-10-26 19:57:11 +01:00
Kacpherek dbd24e2e16 A creaking heart blown up by an explosion makes its creaking die 2026-10-26 19:57:02 +01:00
Kacpherek 5a70691d63 Show the note particle above a note block when it plays 2026-10-26 19:56:39 +01:00
Kacpherek e7e6e5bc07 Scale entity tracking range by entity-broadcast-range-percentage
The property was parsed and ignored. The tracking range of each entity type is now scaled by it on a dedicated server, like DedicatedServer.getScaledTrackingDistance.
2026-10-26 19:56:22 +01:00
Kacpherek 4ebfaef0d3 Slide slowly down the sides of honey blocks 2026-10-26 19:56:07 +01:00
Kacpherek 7112d0cd64 Mobs standing in a wither rose get wither, bees in an open eyeblossom get poison 2026-10-26 19:55:27 +01:00
Kacpherek 818b377c02 Add honey drips, poplar and azalea leaves, falling dust and campfire food smoke 2026-10-26 19:54:32 +01:00
mahirsn 2b2aecd975 fix(client): send sign editor text like vanilla
A new editor or a removed sign closes the open editor (Minecraft.setScreen, AbstractSignEditScreen.tick), and closing sends ServerboundSignUpdatePacket with the lines it opened with when the sign is gone. Sign lines resolve key binds and translations (Component.getString) instead of keeping only plain text.
2026-10-26 21:49:15 +03:00
mahirsn 766bfbcdfa fix(client): answer the server's held slot like vanilla
ClientboundSetHeldSlotPacket only changes the selected hotbar slot (and ignores slots outside the hotbar), so the next tick sends ServerboundSetCarriedItemPacket as vanilla does. Each login starts again from slot 0, as handleLogin's new MultiPlayerGameMode and LocalPlayer do.
2026-10-26 21:48:55 +03:00
Kacpherek 8aad511579 Drop the cursor stack when the dead player respawns 2026-10-26 19:46:01 +01:00
Kacpherek 94600b266f Format the fox module order 2026-10-26 19:44:07 +01:00
Kacpherek 63ee634ee0 Apply an armor stand's saved pose
The Pose tag of an armor stand is now read and saved, drawn on the stand and its armor, and sent to clients on the server thread.
2026-10-26 19:43:42 +01:00
Kacpherek 752138e866 Cats sit on a chest only while nobody has it open, and on a bed's foot only 2026-10-26 19:43:27 +01:00
Kacpherek c74626448c Show the crosshair to spectators over containers, stations and portals 2026-10-26 19:40:06 +01:00
Kacpherek d248ab2605 Give foxes their own goals: flee, hunt, nap in the shade and eat berries
Foxes used the generic animal goals: they panicked at 1.25, never
avoided players or wolves, never hunted, slept, bred or ate berries.
Add mobs/fox.rs with the goals Fox.registerGoals sets up that Horizon
can run today, send the fox variant and flags to clients so the
sleeping pose shows, and let a baby fox take either parent's variant.
2026-10-26 19:37:15 +01:00
Kacpherek 10d2b202d5 Push the other players too with a mace smash 2026-10-26 19:35:53 +01:00
Kacpherek 97f8bca9da Raiders skip ominous banners they cannot reach
A captainless raider walked towards the nearest fallen ominous banner
even when no path led to it. Check that a path reaches the banner before
going for it, and skip a banner without one for 600 ticks, like vanilla.
2026-10-26 19:32:41 +01:00
Kacpherek 484cc945aa Add the rockets list to the remote motion test fixture 2026-10-26 19:23:52 +01:00
Kacpherek 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +01:00
Kacpherek a14da6bb70 Merge origin/main into fix/1302-speed-after-travel 2026-10-26 19:23:02 +01:00
Kacpherek 3114fc66e9 Merge origin/main into fix/2067-deflect-fireballs 2026-10-26 19:22:08 +01:00
Kacpherek ab40cd63e6 Merge origin/main into fix/remote-leash-knots 2026-10-26 19:20:16 +01:00
Kacpherek 00f2663e7b Merge origin/main into fix/harvest-full-beehive 2026-10-26 19:19:20 +01:00
Kacpherek e6587dce6a Respawn at once without a death screen when immediate_respawn is on 2026-10-26 19:17:05 +01:00
Carlos Nunez d75f767925 Merge remote-tracking branch 'upstream/main' into tmp-resolve-2298 2026-10-26 14:13:17 -04:00
Kacpherek a45c73bb36 Credit the player who kills the ender dragon 2026-10-26 19:12:49 +01:00
Kacpherek 91d6bdba85 Fly firework rockets on by themselves between server updates 2026-10-26 19:11:21 +01:00
Kacpherek 4248048ab6 Merge origin/main into fix/elytra-firework-trail 2026-10-26 19:08:50 +01:00
Kacpherek 7f5e3384b3 Play mining hit sounds once, from the server's destroy level events
The server now sends level events 2019 and 2020 every tick a player digs
(ServerPlayerGameMode.tick), and clients handle them like LevelEventHandler:
a crack on the face, plus the hit sound for 2020. A remote client plays only
the first hit itself and no longer adds its own sound every fourth tick, so
the hit was heard twice, out of phase, for every block.
2026-10-26 19:07:11 +01:00
Kacpherek 2277c85fc3 Run neighbour updates when a mob trampling farmland places dirt 2026-10-26 19:07:02 +01:00
Kacpherek e98d939919 Use the mob's own chunk for its local difficulty 2026-10-26 19:06:59 +01:00
Kacpherek fe8565b874 Scale cave spider poison and husk hunger with the difficulty 2026-10-26 18:50:05 +01:00
Kacpherek b32989cf1e Wear shears on mooshrooms and apply Unbreaking to held tool wear 2026-10-26 18:49:13 +01:00
Kacpherek fece9d85e3 Spawn village and swamp hut cats 2026-10-26 18:48:18 +01:00
Kacpherek 7569f9615a Treat unbreakable items as undamageable in the anvil and grindstone 2026-10-26 18:47:58 +01:00
Kacpherek 90501f9ab4 Keep leashed mobs and carrying endermen from despawning
Mob.requiresCustomPersistence also covers a leashed mob and an enderman holding a block, and the spawn caps skip such mobs. Gather the existing cases in one helper, add the missing ones, and use it for both the despawn check and the cap count.
2026-10-26 18:46:46 +01:00
Kacpherek c1844dfe57 Let mobs trample farmland when they land on it
A cow dropped onto farmland left it untouched. A landing mob now turns
farmland to dirt with chance fall distance - 0.5, when mob griefing is on and
the mob is big enough, and is lifted onto the dirt.
2026-10-26 18:46:37 +01:00
Kacpherek bf527e7a13 Apply knockback resistance to Punch arrows 2026-10-26 18:46:36 +01:00
Kacpherek dbef78bcb8 Apply Unbreaking to armor wear from Thorns
Thorns always cost the worn piece 2 durability. Run it through the same
durability loss as other wear so Unbreaking can cancel points.
2026-10-26 18:45:54 +01:00
Kacpherek 564b230ab1 Make a warden's hit disable the player's shield
A warden holds no weapon, so its blows never put the shield on cooldown. Warden.getSecondsToDisableBlocking is a flat 5 seconds, so use that when the attacker is a warden.
2026-10-26 18:45:13 +01:00
Kacpherek 555ddb94bf Use the impaling entity tag for thrown tridents
The bonus went to every swimming or water-breathing mob, including undead,
blazes, ghasts and iron golems. Read the damage from the enchantment data so
only #sensitive_to_impaling mobs take it.
2026-10-26 18:45:13 +01:00
Kacpherek e92937d956 Apply Slowness with Bane of Arthropods
Post-attack effects only handled ignite, so the apply_mob_effect effect of
Bane of Arthropods never did anything. Handle apply_mob_effect like vanilla.
2026-10-26 18:44:23 +01:00
Pavel MikhailovinandClaude Opus 5.5 76d748caf8 Server books: positional page indicator and component-list pages
The page indicator only replaced bare %s, but 26.3's en_us template is
"Page %1$s of %2$s"; it now goes through format_java like other
translations (BookViewScreen.getPageNumberMessage).

Pages sent as a component list (Hypixel through ViaVersion) were dropped
by item_data::read_text, which only took a string or a compound, so the
book opened blank. ComponentSerialization.CODEC also takes a list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:43:35 +03:00
Carlos Nunez d62a1e5d20 Merge remote-tracking branch 'upstream/main' into fix/leave-game-statistic
# Conflicts:
#	tools/lan_e2e.py
2026-10-26 13:33:28 -04:00
Carlos NunezandClaude Sonnet 5.5 1862395e4b Keep a leaving guest's play state until their statistics are saved
lan_crafting_left removed the guest's GuestPlay from host.play before
the leave_game award and save_guest_records ran, so a normal guest
disconnect awarded nothing and wrote no statistics. It now takes the
crafting table and workstation state out of the entry and leaves the
entry for HostEvent::Left to remove after saving.

New lan_e2e check `leave`: a guest leaves twice and the host's saved
statistics show leave_game 1, then 2. It fails on the previous commit
(0, then [0, 0]) and passes here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 13:32:44 -04:00
socksproxandDevin 2c8b933be4 Apply vanilla's view scale to entity render distance
Entity.shouldRenderAtSqrDistance scales the bounding box's mean edge
by 64 * viewScale, and LevelExtractor sets viewScale to the entity
distance option times clamp(renderDistance / 8, 1, 2.5). Horizon used
the option alone, so above render distance 8 entities vanished sooner
than vanilla: a chicken at 32 blocks instead of 48 at the default 12.
RemotePlayer's box also counts ten times.

Fixes #2273

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:30:53 +01:00
socksproxandDevin 077da2e06b Queue the first skin upload as well as re-uploads
The completed-download push into ready never marked the location queued,
so an again() before the next take_ready() put the same skin in twice:
store_skin allocated two slots, and evicting one removed the sprite while
show_skin still found the other entry — the skin never uploaded again.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:30:09 +01:00
Devin 5589edb118 Server packs: swallow gameplay input while the reload progress shows
The progress overlay only changed drawing: a pack pushed while playing
left movement, attack and use live behind it. Input events are now
dropped while the staged reload runs (as vanilla's LoadingOverlay does),
and start_pack_reload releases held keys and mouse buttons up front so
nothing keeps acting across the frame it covers.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:26:50 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol f661443fa9 Read LAN passenger fixture output through the host
Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 17:22:50 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 442d52a038 Release notes: preserve wrapped known issues
Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-26 17:19:04 +00:00
Kacpherek a6d2eb9d64 Shulkers keep their body still while the head turns 2026-10-26 18:17:27 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol 004ad993d9 Reject boarding a horse with an existing mob passenger
Fixes #1759

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 17:16:15 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol 42a57b8286 Merge main and keep both recipe book regressions
Preserve the settings persistence regression alongside main's independent recipe-target check.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 17:15:59 +00:00
Kacpherek 0300ad8922 Keep the current attack target regardless of invisibility 2026-10-26 18:13:33 +01:00
socksproxandDevin 7a3a19697a Resolve dynamic head profiles, and re-upload evicted skins
Player heads whose profile carries only a name or only a UUID
(ResolvableProfile.Dynamic) resolved to nothing, so heads some servers
send that way kept the default skin. Adds skins::Resolver
(ProfileResolver.Cached: profiles/getByName then
session/getProfileById through the services discovery document, on
background threads) used by placed heads, head items and mannequins; the
profile-derived DefaultPlayerSkin stands in until the download lands,
with the name's offline UUID keying it for name-only profiles.

A skin evicted from the atlas's 128 slots could never return: the decoded
image was dropped and show_skin simply failed. The downloader now keeps
the images (and the disk cache backs them), and again queues the upload
again where the tab list, level players, mannequins and heads notice the
missing sprite.

profile_tag keeps id so id-only and name+id profiles survive into the
item data JSON, and the player-head item fallback matches vanilla's
slim/steve (was wide/steve).

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:13:22 +01:00
rdbtCVSandOpenCode GPT-6.1 Sol de141bb7cb Fix recipe book settings persistence on the server
Send open/filter changes to the authoritative player's recipe book and cover packet settings plus save/reopen in both single-player modes.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 17:11:51 +00:00
Kacpherek e8ae26c270 Carry the exploding mob's kind with its blast and anger bees at guests too 2026-10-26 18:09:01 +01:00
SenjinandClaude Opus 5.5 8526305faa HDR: mark only the sun's and moon's discs, not their whole quads
The celestial pass wrote alpha 0 over each quad, so HDR output lifted a
square of bright daytime sky around the sun. It now draws premultiplied
(one, one), which looks the same, and its alpha is how much a texel adds;
the blend takes that off the frame's alpha, so only the discs shine. The
dim End sky, drawn by the same pass, stays unmarked. fs_celestial keeps
its own blend for lightning and additive entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:04:55 +01:00
Devin 5179f97582 Server packs: show a progress screen while a pushed pack's resources reload
The download toast already matched vanilla, but once a server pack was
downloaded the whole resource reload ran synchronously on the render
thread: the frame froze on "Joining world" for seconds where vanilla's
Minecraft.reloadResourcePacks shows a LoadingOverlay with a progress bar.

The reload is split like the startup loader: a worker builds the overlay
root, models and sprites, the menu bundle and the pack's tables while a
progress screen (Preparing resources -> Loading assets -> Loading pack
tables -> Applying resources) covers the frame, then the renderer swap
and resource application land on the render thread and
ServerPacks.reloaded drives the feedback replies, including the recovery
reload on failure. The pack screen's own reload reuses the same staged
load, so its behaviour is unchanged.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:01:04 +01:00
socksproxandDevin b37528f5d6 Pause menu: server links
ClientboundServerLinksPacket (configuration and play) decoded and kept,
the pause menu gains its "Server Links..." button when a server sends
links, opening the server_links dialog screen: scrollable link buttons,
ConfirmLinkScreen on click (chatLinks/chatLinksPrompt honoured), and the
custom-screens warning with its disconnect. A dedicated server sends its
`bug-report-link` property as the BUG_REPORT link, like vanilla.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 17:51:54 +01:00
Eshayat Al-WasiuandCodex 2a98f584df Send cushion breaking particles to nearby multiplayer clients
Co-Authored-By: Codex <[email protected]>
2026-10-26 22:40:40 +06:00
Carlos NunezandClaude Sonnet 5.5 e50eb7c71f Count leave_game for every player who leaves, owner included
Vanilla's PlayerList.remove awards Stats.LEAVE_GAME to a player before
saving them, and PlayerList.removeAll does it for everyone when the
server stops. Horizon only counted it in the in-frame path, so on the
default server thread the owner never got it, and guests who left or
were removed at shutdown did not either.

LanHost::award_leave_game counts it once per guest (a guest is saved
more than once on the way out) and runs where a guest leaves, when the
integrated server stops, and when LAN closes.

Part of #2095 (item 4 of the report). The signal handling and the
remote-world disconnect are #2116's.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 12:36:46 -04:00
Kacpherek b62095dfcb Run the trident player_hurt_entity trigger as the thrower 2026-10-26 17:25:37 +01:00
Kacpherek ba3b567f54 Predict a full beehive reacting to shears and glass bottles on the client 2026-10-26 17:15:29 +01:00
Kacpherek 5d3e9d218d Clean copper for a skeleton trap's visual lightning too 2026-10-26 17:05:35 +01:00
Kacpherek 36ef05b0b9 Keep infinite mob effects through the effect tick 2026-10-26 16:51:22 +01:00
Kacpherek 49ad61035b Count shield damage only for accepted hits and skip kills saved by a totem 2026-10-26 16:47:11 +01:00
SenjinandClaude Opus 5.5 cfd2dc8392 HDR: present HDR10 (PQ) where the surface offers it, scRGB otherwise
HDR10 is what games usually present and what the display receives: a
compositor composes in it or converts to it anyway. The NVIDIA driver
offers it on Wayland next to scRGB. A 10-bit swapchain also halves the
last pass's bandwidth; the frame before it is 8-bit, so nothing is lost.

hdr.wgsl converts the same linear light to BT.2020 (BT.2087) and encodes
it with the ST 2084 curve. HORIZON_HDR_OUTPUT=pq or scrgb allows only
that one, for comparing them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:45:57 +01:00
Kacpherek 3842d851d8 Match the brewed_potion trigger's potion condition as a PotionsPredicate 2026-10-26 16:38:07 +01:00
Kacpherek b28108128d Keep a hopper minecart's items in sync with a leftover menu copy 2026-10-26 16:33:08 +01:00
Kacpherek c50557ea0b Credit the projectile owner for explosion damage to mobs 2026-10-26 16:29:05 +01:00
Kacpherek f364c4f9c6 Leave wandering traders out of the spawn egg baby check 2026-10-26 16:28:28 +01:00
Kacpherek 18f4dd92ae Play container open and close sounds only when the opener count crosses zero 2026-10-26 16:24:42 +01:00
Kacpherek 47654b62a3 Save the armadillo scute timer and reset it only when a scute can drop 2026-10-26 16:24:26 +01:00
Kacpherek 2f3dade42f Show the firework trail on a boosting rocket and hide its sprite
On a server-thread client an elytra boost rocket drew no spark trail and
showed up as a static item sprite. The client now follows the entity the
rocket is attached to, spawns the trail each tick like
FireworkRocketEntity.tick, and does not draw an attached rocket.
2026-10-26 16:19:53 +01:00
Kacpherek 6f4c2cfbbc Count the hurt tilt down while the player is dead or riding 2026-10-26 16:16:19 +01:00
Kacpherek af49fd7dc3 Use a collision-shape raycast for the spyglass line-of-sight check 2026-10-26 16:07:14 +01:00
Kacpherek af6faa88e5 Report the hook's open-water flag to the fishing_rod_hooked trigger 2026-10-26 16:06:30 +01:00
Kacpherek e71d4281ac Spawn a baby when a spawn egg is used on its own mob kind
Mob.checkAndHandleImportantInteractions: a spawn egg of the clicked mob's
kind spawns a baby at the mob's position (SpawnEggItem.spawnOffspringFromSpawnEgg),
inheriting the parent's variant, and uses one egg outside creative.

Fixes #2269.
2026-10-26 16:04:35 +01:00
rdbtCVSandOpenAI GPT-6.1 Sol 9e71837df9 Keep server logging from panicking on a closed stdout pipe
Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 14:56:15 +00:00
Kacpherek 26626e8c27 Give frogs vanilla's smooth swimming move control 2026-10-26 15:45:51 +01:00
Kacpherek 7102727197 Play the container close sound when a server-thread player closes it 2026-10-26 15:24:26 +01:00
Kacpherek 70868f3208 Give Oozing, Infested, Weaving and Wind Charged their effects
These four 26.x effects only existed as potion entries. Oozing spawns two slimes, Weaving places cobwebs and Wind Charged makes a gust when the bearer dies. Infested sometimes releases silverfish when the bearer is hurt.
2026-10-26 15:20:34 +01:00
Kacpherek aff673a08d Give the Ender Dragon its ambient sound 2026-10-26 15:20:10 +01:00
Kacpherek 2e642857fc Fire the levitation and using_item advancement triggers 2026-10-26 15:20:03 +01:00
Kacpherek 147e5269c7 Play the rain sound the way vanilla picks it 2026-10-26 15:19:20 +01:00
Kacpherek b3a499d85d Play chest, barrel and shulker box sounds with a random pitch from the right spot 2026-10-26 15:18:05 +01:00
Kacpherek b913dab5e5 Walk, follow and flee at the camel's real AI speeds 2026-10-26 15:17:53 +01:00
Kacpherek 8c8f857313 Fire item_used_on_block when an axe, honeycomb, disc or vault key is used on a block 2026-10-26 15:17:42 +01:00
Kacpherek 9c427dfae0 Mobs killed by a wither leave a wither rose 2026-10-26 15:16:54 +01:00
Kacpherek 13144a82a1 Pull the third-person camera back further when riding a happy ghast 2026-10-26 15:16:51 +01:00
Kacpherek ef711f8b1f Fire the brewed_potion trigger when a potion is taken from a brewing stand 2026-10-26 15:16:44 +01:00
Kacpherek 601e7918a8 Lightning scrapes one oxidation step off a copper golem
A copper golem hit by a bolt goes back one weathering step, and its
weathering timer (or wax) is reset.
2026-10-26 15:16:43 +01:00
Kacpherek dd3697bb4d Hopper minecarts take from the container above, one item per tick 2026-10-26 15:16:43 +01:00
Kacpherek 273729aac8 Count the combat statistics
damage_resisted, damage_absorbed and damage_blocked_by_shield now add up when
Resistance, absorption hearts or a shield take damage, and a player who lands
the killing blow on another player gets player_kills.
2026-10-26 15:16:34 +01:00
Kacpherek 728bf14aa7 Use vanilla ambient sound intervals for guardians, hoglins, shulkers and allays
A happy ghast with a passenger waits six times as long.
2026-10-26 15:16:06 +01:00
Kacpherek 53501ab94e Merge a mob's repeated effects like vanilla
A stronger effect now replaces the old one with its own duration, instead of taking the longer duration and the higher level separately.
2026-10-26 15:15:55 +01:00
Kacpherek 85a6ce0921 Lightning scrapes oxidation off copper
A struck copper block goes back to unoxidized, and 3 to 5 random walks
take one step of oxidation off the weathering copper around it. Waxed
blocks stay as they are but still start the walks.
2026-10-26 15:15:30 +01:00
Kacpherek fd0f09d6f6 End the Absorption effect once the absorption is used up
The effect used to stay in the list until its timer ran out, even with no absorption hearts left.
2026-10-26 15:15:20 +01:00
Kacpherek 06fb03567e Count the block interaction statistics
Eating a cake slice, using a cauldron (fill, use, clean armor, banner or shulker
box), tuning a note block, ringing a bell, opening an ender chest and putting a
record in a jukebox now add to their statistics, which stayed at 0 before.
2026-10-26 15:15:11 +01:00
Kacpherek c418034f10 Let goats breed, follow wheat and be milked
Goats only strolled and panicked, so wheat did nothing after the love
hearts and a bucket gave no milk. Give them the GoatAi core and idle
behaviours that exist here (panic 2.0, breed, tempt 1.25, baby follow
1.25) and Goat.mobInteract's milking with the goat milk sounds.
2026-10-26 15:15:07 +01:00
Kacpherek c273a7271e Make breeze shots follow the world difficulty and aim higher at riders 2026-10-26 15:14:59 +01:00
Kacpherek 5b9a66089e Give a partial item pickup the random pitch of a full pickup 2026-10-26 15:14:43 +01:00
Kacpherek 4c72a3a44f Make a detector rail's comparator read the minecart on it 2026-10-26 15:14:42 +01:00
Kacpherek 8691d9a8e8 Let players harvest a full beehive with shears or a glass bottle 2026-10-26 15:14:34 +01:00
Kacpherek 79bfb8fcb8 Let a brush take a scute off an armadillo
Right-clicking an adult armadillo with a brush did nothing. Follow
Armadillo.mobInteract: drop a scute from the brush loot table, play the
brush sound, post the entity_interact game event and wear the brush by 16.
2026-10-26 15:14:26 +01:00
Kacpherek 8d77913ea5 Note blocks play on a left click, and take mob heads on top
Mining a note block now plays its note and counts the play_noteblock
statistic. A mob head used on the top face is placed instead of tuning
the block.
2026-10-26 15:14:10 +01:00
Kacpherek 5fa423169f Fire player_hurt_entity and entity_hurt_player advancement triggers 2026-10-26 15:13:51 +01:00
Kacpherek 183382cd2b Fire the fishing_rod_hooked trigger when a hook is reeled in 2026-10-26 15:13:34 +01:00
Kacpherek 84aba7b3d4 Count talking to villagers under vanilla's statistic name
The statistic was written as talk_to_villager, but vanilla's key is
talked_to_villager, so the statistics screen and scoreboard criteria always
showed 0. Wandering traders already count through the same code.
2026-10-26 15:13:27 +01:00
Kacpherek 329ce1b724 Make armadillos shed scutes on their own
Armadillos never dropped scutes because nothing ran their shed timer.
Follow Armadillo.customServerAiStep: count down 5 to 10 minutes, then
drop a scute, play the sound and post the entity_place game event.
2026-10-26 15:13:27 +01:00
Kacpherek e2a399aff0 Reopen signs on right click and apply dyes, ink sacs and honeycomb
Right-clicking a placed sign opened an empty inventory screen. It now
opens the editor for the side the player faces, or plays the waxed-sign
fail sound. Dyes, glow ink sacs, ink sacs and honeycomb change the
colour, glow or wax of the side the player faces and are used up.
2026-10-26 15:13:19 +01:00
Kacpherek 2a0824cf5f Invisible players are harder for mobs to notice
Mobs now scale their detection range by 0.7 times the worn armor fraction (at least 0.1) when the player has Invisibility, like vanilla's getVisibilityPercent.
2026-10-26 15:13:15 +01:00
Kacpherek a4d0f76edd Count movement statistics as vanilla does
Fall distance is counted once per landing from 2 blocks or more instead of on
every downward tick. Riding counts only for the mounts vanilla lists (pig,
horse family by full distance, strider, happy ghast, nautilus, boat, minecart),
and airborne sideways moves over 25 cm count as flying.
2026-10-26 15:13:08 +01:00
Kacpherek 00e2cfec29 Let tadpoles be fed slime balls to grow up faster 2026-10-26 15:12:57 +01:00
Kacpherek a5fa40d2ee Fire the villager_trade trigger so What a Deal! can be earned 2026-10-26 15:12:48 +01:00
Kacpherek cc39d57e07 Explosions empty and anger the bees of destroyed hives 2026-10-26 15:12:42 +01:00
Kacpherek 0a5e80de7b Step out of a minecart in vanilla's order 2026-10-26 15:12:33 +01:00
Kacpherek b7c47ccbf9 Let a sneaking player open chest and hopper minecarts 2026-10-26 15:11:52 +01:00
Kacpherek 9c3450622b Update comparators when a copper golem moves items in or out of a chest 2026-10-26 15:11:49 +01:00
Kacpherek 3615c65d18 Make allays drop what they carry on death and use the default pickup delay when throwing 2026-10-26 15:11:01 +01:00
Kacpherek 7cc8def23f Let a lit TNT minecart explode when it hits a wall 2026-10-26 15:10:57 +01:00
Kacpherek e553db9bb5 Let frogs take fall damage five points below the base amount 2026-10-26 15:10:09 +01:00
Kacpherek 790b191ed1 Bees let out of a sedated hive stay out for 400 ticks 2026-10-26 15:09:20 +01:00
Kacpherek d136dda092 Bee stings only poison on Normal and Hard 2026-10-26 15:08:48 +01:00
Kacpherek a8205dd80d Infested blocks release their silverfish when broken 2026-10-26 13:21:45 +01:00
Kacpherek f252941ac1 Charge melee exhaustion only when the hit hurts 2026-10-26 13:20:39 +01:00
Kacpherek 30fa41ddbd Make the fall, drowning, fire and freeze damage game rules work 2026-10-26 13:20:37 +01:00
Kacpherek e2781f7939 Apply suspicious stew effects when the stew is eaten 2026-10-26 13:20:35 +01:00
Joansitoh 2f6f0e3e0f Launcher: Game arguments edits at the caret
The field only appended and popped the last character, and Space typed
nothing on Windows (it arrives without a character). A shared one-line
field, launcher/src/line_edit.rs, now holds the text, caret, selection
and undo history: arrows, Home/End, Ctrl+arrows by word, Shift to select,
mouse click, drag, double and triple click, Ctrl+A/C/X/V and Ctrl+Z / Ctrl+Y.
2026-10-26 13:16:35 +01:00
Kacpherek 8697f3fd3f Furnaces: drying a wet sponge fills the bucket in the fuel slot 2026-10-26 13:13:32 +01:00
Kacpherek 6bc8a8b889 Send sweep, damage indicator and fishing particles to a server's players
Vanilla sends these with ServerLevel.sendParticles, so every player
nearby sees them. Horizon drew them only in the host window's own
particle engine, which a window on a server (and a guest next to the
host) never sees.

One helper now sends a ClientboundLevelParticlesPacket with the same
count, offset and speed to each player within 32 blocks. While the host
holds the players (a guest's turn, a host hit on a guest) the packet
waits and goes out when the tick ends.
2026-10-26 13:09:45 +01:00
Kacpherek 4825e537cc Tilt a server player's camera towards the hit
The server never sent ClientboundHurtAnimationPacket, and the client
ignored the yaw it carried, so a player on a server got the flash but the
camera never tilted towards where the hit came from.

ServerPlayer.indicateDamage now runs where the hit's knockback is dealt
(and with no direction for hits that have none), the packet goes out when
the guest's turn ends, and the client keeps the yaw as Player.hurtDir for
GameRenderer.bobHurt.
2026-10-26 13:05:51 +01:00
Kacpherek 2e91bd84e9 Send villager, taming, love, spawn poof and snowball entity events to server clients
Single player on the server thread drew these particles only into the server's own engine. The server now sends the entity event byte where vanilla does (20, 12/13/14, 6/7, 18 and 3), and the client draws it in handleEntityEvent's way.
2026-10-26 13:05:47 +01:00
Kacpherek 5c0f7142f3 Draw the level events the client dropped: lava and torch smoke, drips, chorus, sniffer eggs, dragon fireball burst 2026-10-26 13:03:44 +01:00
Kacpherek 3287a74d64 Send fence knots to server players so leads tied to fences are drawn 2026-10-26 13:00:40 +01:00
Kacpherek 09078949d7 Send a guest's explosion to the players once their turn ends
An end crystal (or minecart) hit by a player on the server thread blew up
with sound and damage but no explosion particles. The blast runs inside the
guest's turn, while the host holds the players, so lan_explode had no hub to
send the explosion packet through and dropped it. Queue it like a wind burst
and send it when the turn is over.
2026-10-26 12:36:29 +01:00
Kacpherek 265965445d Save the world when the game gets SIGTERM, Ctrl+C or SIGHUP, and disconnect on native quit 2026-10-26 12:34:59 +01:00
Kacpherek f5f7448909 Lingering potions leave an effect cloud
A thrown lingering potion used to act as one weaker splash. It now leaves an area effect cloud like vanilla: radius 3, a 10 tick wait, 600 ticks, shrinking as it goes and by 0.5 each time it affects someone. Every 5 ticks it affects the mobs and players inside, with a 20 tick delay per entity, at a quarter of the duration (instant effects at half strength). Server players see it as an area_effect_cloud, like the dragon's breath clouds.
2026-10-26 12:31:40 +01:00
Kacpherek 9e3956282a Send a deflected fireball's new direction to the player at once
A ghast fireball or wind charge hit back by a player was turned on the server, but the player kept seeing it fly on until its next update ten ticks later. Mark the turn to be sent right away, as vanilla does with needsSync, and reset the acceleration power to 0.1 as an attack deflection does.
2026-10-26 12:18:36 +01:00
Ludovic MarieandClaude Opus 5.5 19a5098b14 Leave a respawned body's reset to the swap that builds it
Respawning drops the guest's body, so the next swap builds a fresh one,
which now clears what the old body was given (equipment, effects and
armor protection) itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:03:41 +01:00
Kacpherek a7ff22eea0 Give a server player's new body its effects at once after a level change
A server's player gets a new body when it joins, respawns or changes
level, but what the old body had been given by its effects (the applied
attribute modifiers and the armor protection table) was kept in its
per-player record. apply_effect_status then saw nothing to change, so
after a trip to another dimension Speed kept its icon while the body's
movement speed fell back from 0.14 to 0.10, until the effect was
reapplied.

Vanilla keeps the same ServerPlayer through ServerPlayer.teleport into
another level, with its attributes and MobEffect modifiers. A new body
now starts with nothing applied, and its effects are applied as soon as
it is created, before the movement that brought it is handled.
2026-10-26 00:16:57 +01:00
426 changed files with 38391 additions and 3898 deletions

No files matched your search

+3
View File
@@ -72,6 +72,9 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
# 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.
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
# 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
+274
View File
@@ -0,0 +1,274 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
# which never runs what this one built. See docs/release-flow.md.
on:
workflow_call:
inputs:
ref:
description: The commit to build
type: string
required: true
version:
description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28
type: string
required: true
nightly:
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
type: string
default: ''
developer_id:
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
type: boolean
default: true
permissions:
contents: read
jobs:
exporters:
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
run: |
set -euo pipefail
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
# prepares afresh.
cargo build --release --locked -p horizon-prepare
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
# write those (vanilla-data.sh links .local/exporters to them).
rm -rf .local/exporters
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
# Only our classes and their manifest leave this runner.
stray=$(find exporters -type f ! -path exporters/manifest.json \
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
with:
name: original-exporters
path: exporters/
if-no-files-found: error
retention-days: 3
build:
needs: exporters
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ inputs.version }}
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
HORIZON_NIGHTLY: ${{ inputs.nightly }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
with:
name: original-exporters
path: exporters
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
run: packaging/macos/keychain.sh lock
+166
View File
@@ -0,0 +1,166 @@
name: nightly build
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night it builds the newest
# union branch on origin (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or
# main when no union is newer, and publishes it as the `nightly` pre-release. Launchers install it only for players
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the union's code:
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
# - `build` (game-build.yml) checks out and compiles the union with no secrets: its token reads contents only and
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
# which stays locked for nightlies).
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
# runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
on:
schedule:
- cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini
workflow_dispatch:
inputs:
force:
description: Build and publish even if tonight's pick is the commit already published
type: boolean
default: false
dry_run:
description: Build and sign with a throwaway key, publish nothing
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly-build
cancel-in-progress: false
jobs:
pick:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
source: ${{ steps.pick.outputs.source }}
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- id: mode
# A dry run never publishes; a hand-started run off main is always one.
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
- id: pick
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
path: nightly-notes.md
retention-days: 3
build:
needs: pick
if: needs.pick.outputs.build == 'true'
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.pick.outputs.sha }}
version: ${{ needs.pick.outputs.version }}
nightly: ${{ needs.pick.outputs.nightly }}
developer_id: false
sign:
needs: [pick, build]
runs-on: ubuntu-latest
timeout-minutes: 10
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.pick.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [pick, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
- name: The smoke test drew a world
# No release-blocker check: a nightly is how blockers get tested.
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
- name: Publish as the `nightly` pre-release
if: needs.pick.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
run: |
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
ls -l dist
+10 -245
View File
@@ -40,7 +40,10 @@ permissions:
# The open release-blocker issues (decide).
issues: read
concurrency:
group: game-release
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
# cancelled it (#2779's dry run, 2026-10-27), which happens all day while releases go out in batches.
group: game-release${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '-dry-run' || '' }}
cancel-in-progress: false
jobs:
decide:
@@ -79,252 +82,14 @@ jobs:
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
exporters:
build:
needs: decide
if: needs.decide.outputs.release == 'true'
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
run: |
set -euo pipefail
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
# prepares afresh.
cargo build --release --locked -p horizon-prepare
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
# write those (vanilla-data.sh links .local/exporters to them).
rm -rf .local/exporters
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
# Only our classes and their manifest leave this runner.
stray=$(find exporters -type f ! -path exporters/manifest.json \
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
with:
name: original-exporters
path: exporters/
if-no-files-found: error
retention-days: 3
build:
needs: [decide, exporters]
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
with:
name: original-exporters
path: exporters
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: packaging/macos/keychain.sh lock
# Every platform's archive, and macOS's smoke test (game-build.yml).
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.decide.outputs.head }}
version: ${{ needs.decide.outputs.version }}
sign:
needs: [decide, build]
+3 -1
View File
@@ -10,7 +10,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
@@ -65,6 +66,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
Generated
+14
View File
@@ -930,6 +930,13 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "far-view"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1398,6 +1405,7 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
@@ -3767,6 +3775,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
+1
View File
@@ -35,6 +35,7 @@ members = [
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
@@ -61,6 +61,16 @@ fn f32_tag(v: f32) -> Tag {
Tag::Float(v.to_bits())
}
/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds.
pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> {
let Tag::IntArray(v) = tag else { return None };
let p: [i32; 3] = v.as_slice().try_into().ok()?;
((-30_000_000..30_000_000).contains(&p[0])
&& (-20_000_000..20_000_000).contains(&p[1])
&& (-30_000_000..30_000_000).contains(&p[2]))
.then_some(p)
}
/// What a block entity reads and writes, in `saveAdditional` order.
struct Fields<'a> {
input: &'a Tag,
@@ -424,10 +434,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:end_gateway" => {
f.long("Age", 0);
f.keep("exit_portal", |t| {
is_pos(t)
&& matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000)
});
f.keep("exit_portal", |t| gateway_exit(t).is_some());
if flag(tag.get("ExactTeleport")) == Some(true) {
f.put("ExactTeleport", boolean(true));
}
@@ -173,6 +173,22 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
Some("open_url") => text("url")
.filter(|u| {
u.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
})
.map(crate::text::ClickEvent::OpenUrl),
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
+18 -1
View File
@@ -4,7 +4,10 @@
//! language used by every screen.
use std::{
collections::{BTreeMap, HashMap},
sync::{Arc, RwLock},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -23,11 +26,16 @@ impl LanguageInfo {
#[derive(Default)]
pub struct Language {
pub code: String,
/// `LanguageInfo.bidirectional`: text without a strong character reads
/// right to left (`ClientLanguage.defaultRightToLeft`).
bidirectional: bool,
map: HashMap<String, String>,
}
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
@@ -134,8 +142,12 @@ impl Language {
{
apply_deprecated(&mut map, &bytes);
}
let bidirectional = available(sources)
.iter()
.any(|info| info.code == code && info.bidirectional);
Self {
code: code.to_owned(),
bidirectional,
map,
}
}
@@ -197,11 +209,16 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
}
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
}
pub fn code() -> String {
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
}
+5 -2
View File
@@ -1,8 +1,9 @@
//! The client's lowest layer, below commands and gameplay: chat text and
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
//! bars, game rules, block entity NBT tables, and the registry, particle and
//! sound source tables commands name. horizon-client re-exports each module
//! under its old path (`horizon_client::text`, ...).
//! sound source tables commands name, and the `pack.mcmeta` filter regex.
//! horizon-client re-exports each module under its old path
//! (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
@@ -14,6 +15,8 @@ pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod particle_type;
pub mod pattern;
pub mod potions;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
+249
View File
@@ -0,0 +1,249 @@
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
//! tables): each effect's colour and category, each potion's effects, and the
//! potion colour mix. horizon-client re-exports them from `brewing`.
use std::{collections::HashMap, sync::OnceLock};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
Beneficial,
Harmful,
Neutral,
}
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
pub color: u32,
pub category: Category,
pub instant: bool,
}
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
EffectDef {
id,
color,
category,
instant: false,
}
}
use Category::{Beneficial as B, Harmful as H, Neutral as N};
/// `MobEffects` in registration order.
pub const EFFECTS: &[EffectDef] = &[
fx("speed", 3402751, B),
fx("slowness", 9154528, H),
fx("haste", 14270531, B),
fx("mining_fatigue", 4866583, H),
fx("strength", 16762624, B),
EffectDef {
id: "instant_health",
color: 16262179,
category: B,
instant: true,
},
EffectDef {
id: "instant_damage",
color: 11101546,
category: H,
instant: true,
},
fx("jump_boost", 16646020, B),
fx("nausea", 5578058, H),
fx("regeneration", 13458603, B),
fx("resistance", 9520880, B),
fx("fire_resistance", 16750848, B),
fx("water_breathing", 10017472, B),
fx("invisibility", 16185078, B),
fx("blindness", 2039587, H),
fx("night_vision", 12779366, B),
fx("hunger", 5797459, H),
fx("weakness", 4738376, H),
fx("poison", 8889187, H),
fx("wither", 7561558, H),
fx("health_boost", 16284963, B),
fx("absorption", 2445989, B),
EffectDef {
id: "saturation",
color: 16262179,
category: B,
instant: true,
},
fx("glowing", 9740385, N),
fx("levitation", 13565951, H),
fx("luck", 5882118, B),
fx("unluck", 12624973, H),
fx("slow_falling", 15978425, B),
fx("conduit_power", 1950417, B),
fx("dolphins_grace", 8954814, B),
fx("bad_omen", 745784, N),
fx("hero_of_the_village", 4521796, B),
fx("darkness", 2696993, H),
fx("trial_omen", 1484454, N),
fx("raid_omen", 14565464, N),
fx("wind_charged", 12438015, H),
fx("weaving", 7891290, H),
fx("oozing", 10092451, H),
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
pub struct PotionDef {
pub id: &'static str,
pub name: &'static str,
pub effects: &'static [(&'static str, i32, i32)],
}
const fn p(
id: &'static str,
name: &'static str,
effects: &'static [(&'static str, i32, i32)],
) -> PotionDef {
PotionDef { id, name, effects }
}
/// `Potions` in registration order.
pub const POTIONS: &[PotionDef] = &[
p("water", "water", &[]),
p("mundane", "mundane", &[]),
p("thick", "thick", &[]),
p("awkward", "awkward", &[]),
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
p(
"long_night_vision",
"night_vision",
&[("night_vision", 9600, 0)],
),
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
p(
"long_invisibility",
"invisibility",
&[("invisibility", 9600, 0)],
),
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
p(
"fire_resistance",
"fire_resistance",
&[("fire_resistance", 3600, 0)],
),
p(
"long_fire_resistance",
"fire_resistance",
&[("fire_resistance", 9600, 0)],
),
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
p("slowness", "slowness", &[("slowness", 1800, 0)]),
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
p(
"turtle_master",
"turtle_master",
&[("slowness", 400, 3), ("resistance", 400, 2)],
),
p(
"long_turtle_master",
"turtle_master",
&[("slowness", 800, 3), ("resistance", 800, 2)],
),
p(
"strong_turtle_master",
"turtle_master",
&[("slowness", 400, 5), ("resistance", 400, 3)],
),
p(
"water_breathing",
"water_breathing",
&[("water_breathing", 3600, 0)],
),
p(
"long_water_breathing",
"water_breathing",
&[("water_breathing", 9600, 0)],
),
p("healing", "healing", &[("instant_health", 1, 0)]),
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
p("harming", "harming", &[("instant_damage", 1, 0)]),
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
p("poison", "poison", &[("poison", 900, 0)]),
p("long_poison", "poison", &[("poison", 1800, 0)]),
p("strong_poison", "poison", &[("poison", 432, 1)]),
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
p(
"long_regeneration",
"regeneration",
&[("regeneration", 1800, 0)],
),
p(
"strong_regeneration",
"regeneration",
&[("regeneration", 450, 1)],
),
p("strength", "strength", &[("strength", 3600, 0)]),
p("long_strength", "strength", &[("strength", 9600, 0)]),
p("strong_strength", "strength", &[("strength", 1800, 1)]),
p("weakness", "weakness", &[("weakness", 1800, 0)]),
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
p("luck", "luck", &[("luck", 6000, 0)]),
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
p(
"long_slow_falling",
"slow_falling",
&[("slow_falling", 4800, 0)],
),
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
p("weaving", "weaving", &[("weaving", 3600, 0)]),
p("oozing", "oozing", &[("oozing", 3600, 0)]),
p("infested", "infested", &[("infested", 3600, 0)]),
];
pub fn potion(id: &str) -> Option<&'static PotionDef> {
let id = id.trim_start_matches("minecraft:");
POTIONS.iter().find(|p| p.id == id)
}
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, _, amp) in def.effects {
let Some(e) = effect(e) else { continue };
let w = *amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
total += w;
}
match (
r.checked_div(total),
g.checked_div(total),
b.checked_div(total),
) {
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
_ => BASE_POTION_COLOR,
}
}
+6
View File
@@ -42,6 +42,10 @@ pub fn named_color(name: &str) -> Option<u32> {
/// Click actions carried by locally constructed text components.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
/// `ClickEvent.OpenUrl`: an http or https link.
OpenUrl(String),
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
ChangePage(i32),
SuggestCommand(String),
CopyToClipboard(String),
/// A local file to open with the system handler.
@@ -529,6 +533,8 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
}
if let Some(click) = &style.click {
let event = match click {
ClickEvent::OpenUrl(url) => json!({ "action": "open_url", "url": url }),
ClickEvent::ChangePage(page) => json!({ "action": "change_page", "page": page }),
ClickEvent::SuggestCommand(command) => {
json!({ "action": "suggest_command", "command": command })
}
+1
View File
@@ -62,6 +62,7 @@ gilrs = { version = "0.11", optional = true }
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
@@ -163,6 +163,8 @@ pub fn main() -> Result<()> {
ArrowSound::HitGround => "hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
ArrowSound::TridentHitGround => "trident_hit_ground",
ArrowSound::TridentHit => "trident_hit",
},
volume.to_bits(),
pitch.to_bits()
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
use Species as S;
let s = m.species();
let named = |reg: &finalize::VariantRegistry, i: u8| {
format!(
"minecraft:{}",
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
let name = i.map_or("classic", |i| {
reg.names.get(i as usize).map_or("?", |n| n.as_str())
)
});
format!("minecraft:{name}")
};
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
match s {
@@ -580,7 +580,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius) => {
MobEvent::Explode(at, radius, _) => {
let d = Vec3::new(at[0], at[1], at[2])
.distance_squared(p.position)
.sqrt();
@@ -1050,17 +1050,21 @@ pub fn main() -> horizon_client::Result<()> {
off_hand: None,
head: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
gold_armor: false,
hurt_by: None,
last_hurt: None,
velocity: Vec3::ZERO,
yaw: 0.0,
sprinting: false,
riding: false,
hero: false,
effects: Vec::new(),
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -1070,6 +1074,8 @@ pub fn main() -> horizon_client::Result<()> {
for (i, m) in mobs.iter().enumerate() {
print(&mut out, 0, i, m, s.creative, world.game_time);
}
// No player has a chest open in the scenarios.
let lid_openers = std::collections::HashMap::new();
// The MobWorld every step sees, over a snapshot of the mobs.
macro_rules! mob_world {
($snapshot:expr) => {
@@ -1081,6 +1087,7 @@ pub fn main() -> horizon_client::Result<()> {
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
@@ -1098,12 +1105,14 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
sea_level: -63,
items: Some(&items),
lid_openers: &lid_openers,
}
};
}
@@ -5,6 +5,10 @@
//!
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::recipes::RecipeBookChangeSettings,
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
@@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> {
_ => BookType::Smoker,
}
.index();
book.open[i] = op["open"].as_bool().unwrap_or(false);
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
let packet = RecipeBookChangeSettings {
book_type: i as i32,
open: op["open"].as_bool().unwrap_or(false),
filtering: op["filtering"].as_bool().unwrap_or(false),
};
let mut wire = Writer::new();
RecipeBookChangeSettings::write(&packet, &mut wire);
extra = format!(
" packet={}",
wire.data
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
);
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
book.open[decoded.book_type as usize] = decoded.open;
book.filtering[decoded.book_type as usize] = decoded.filtering;
}
"save" => {
let tag = book.write();
@@ -353,6 +353,7 @@ pub fn main() -> Result<()> {
horizon_world::food::Difficulty::Hard,
][op["difficulty"].as_u64().unwrap() as usize],
natural_regeneration: op["regenerate"].as_bool().unwrap(),
..Default::default()
};
}
"food" => {
+117 -21
View File
@@ -437,6 +437,8 @@ pub struct EntityView {
/// mainhand, offhand, head, chest, legs, feet, body.
pub equipment: [Option<ItemView>; 7],
pub vehicle: Option<Box<EntityView>>,
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
pub looking_at: Option<Box<EntityView>>,
pub effects: Vec<(String, i32, i32)>,
/// Player: game mode name.
pub gamemode: Option<String>,
@@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool {
_ => false,
}
}
fn range_i(v: &Value, x: i64) -> bool {
pub fn range_i(v: &Value, x: i64) -> bool {
range_f(v, x as f64)
}
/// A registry id, `#tag`, or list matched against one id.
@@ -473,6 +475,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
_ => false,
}
}
/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate`
/// over the potion's effects (`contains`, `count`, `size`).
pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
if v.is_null() {
return true;
}
if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w))
{
return false;
}
let c = &v["effects"];
if c.is_null() {
return true;
}
let effects: Vec<(String, i32, i32)> = potion
.and_then(crate::brewing::potion)
.map(|d| {
d.effects
.iter()
.map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur))
.collect()
})
.unwrap_or_default();
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
let test = |pred: &Value, e: &(String, i32, i32)| {
pred.as_object().is_some_and(|m| {
m.get(&e.0).is_some_and(|want| {
range_i(&want["amplifier"], i64::from(e.1))
&& range_i(&want["duration"], i64::from(e.2))
&& want["ambient"].as_bool().is_none_or(|b| !b)
&& want["visible"].as_bool().is_none_or(|b| b)
})
})
};
c["contains"]
.as_array()
.is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e))))
&& c["count"].as_array().is_none_or(|l| {
l.iter().all(|entry| {
let n = effects.iter().filter(|e| test(&entry["test"], e)).count();
range_i(&entry["count"], n as i64)
})
})
&& range_i(&c["size"], effects.len() as i64)
}
/// `ItemPredicate`.
pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
if v.is_null() {
@@ -614,6 +661,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool {
})
})
}
/// `DistancePredicate.matches` between two points.
pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
range_f(&v["x"], d[0].abs())
&& range_f(&v["y"], d[1].abs())
&& range_f(&v["z"], d[2].abs())
&& range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
&& range_f(
&v["absolute"],
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
/// `EntityPredicate` (26.x keyed sub-predicates).
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
@@ -629,21 +688,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
"minecraft:stepping_on" => {
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
}
"minecraft:distance" | "distance" => {
let d = [
e.position[0] - origin[0],
e.position[1] - origin[1],
e.position[2] - origin[2],
];
range_f(&p["x"], d[0].abs())
&& range_f(&p["y"], d[1].abs())
&& range_f(&p["z"], d[2].abs())
&& range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
&& range_f(
&p["absolute"],
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
"minecraft:distance" | "distance" => distance(p, e.position, origin),
"minecraft:flags" | "flags" => {
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
f("is_on_fire", e.on_fire)
@@ -678,11 +723,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
})
})
}),
"minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| {
e.gamemode
.as_deref()
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
}),
"minecraft:type_specific/player" => {
p["gamemode"].as_array().is_none_or(|modes| {
e.gamemode
.as_deref()
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
}) && (p["looking_at"].is_null()
|| e.looking_at
.as_ref()
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"minecraft:type_specific/lightning" => {
range_i(&p["blocks_set_on_fire"], 0)
&& (p["entity_struck"].is_null()
@@ -703,6 +753,52 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
/// The damage type id (`minecraft:arrow`).
pub kind: String,
/// `DamageSource.getDirectEntity` and `getEntity`.
pub direct: Option<EntityView>,
pub source: Option<EntityView>,
/// `DamageSource.isDirect`: the direct entity is the causing one.
pub is_direct: bool,
/// The damage before and after the shield took its share.
pub original: f32,
pub actual: f32,
pub blocked: bool,
}
/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`.
pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
return true;
}
let by = |key: &str, e: &Option<EntityView>| {
v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin))
};
if !range_f(&v["dealt"], f64::from(d.original))
|| !range_f(&v["taken"], f64::from(d.actual))
|| !by("source_entity", &d.source)
|| v["blocked"].as_bool().is_some_and(|b| b != d.blocked)
{
return false;
}
let ty = &v["type"];
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
let tag = t["id"].as_str().unwrap_or("");
let tag = tag.strip_prefix('#').unwrap_or(tag);
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
});
let direct_ok = ty["direct_entity"].is_null()
|| d.direct
.as_ref()
.is_some_and(|e| entity(&ty["direct_entity"], e, w, origin));
let source_ok = ty["source_entity"].is_null()
|| d.source
.as_ref()
.is_some_and(|e| entity(&ty["source_entity"], e, w, origin));
tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct)
}
/// The entities a loot-style condition list may refer to.
pub struct Entities<'a> {
pub this: Option<&'a EntityView>,
+292 -21
View File
@@ -35,8 +35,10 @@ enum Animate {
Campfire {
spawn_particles: bool,
},
/// Leaves: optional falling-leaf particle and chance, tinted by foliage.
Leaves(Option<(T, f32)>),
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
/// the biome foliage colour unless a fixed colour is given (azalea); and
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
Leaves(Option<(T, f32)>, Option<u32>, bool),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -47,8 +49,11 @@ enum Animate {
CandleCake,
BrewingStand,
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// FallingBlock with its dust colour; sand also has the ambient desert sound
/// (`SandBlock.animateTick`).
Falling(u32, bool),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
RedstoneWire {
power: u8,
@@ -70,6 +75,8 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// `WetSpongeBlock.animateTick`.
WetSponge,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -104,10 +111,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
spawn_particles: false,
},
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01))),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))),
"spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None),
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
"azalea_leaves" | "flowering_azalea_leaves" => {
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
}
"spruce_leaves" => Animate::Leaves(None, None, false),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -116,9 +130,18 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"mycelium" => Animate::Mycelium,
"brewing_stand" => Animate::BrewingStand,
"ender_chest" => Animate::EnderChest,
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
"sand" => Animate::Falling(0xDBD3A0, true),
"red_sand" => Animate::Falling(0xA95821, true),
"gravel" => Animate::Falling(0x807C7B, false),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
"dragon_egg" => Animate::Falling(0x000000, false),
_ if name.ends_with("_concrete_powder") => Animate::Falling(
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
false,
),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0);
@@ -149,6 +172,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -161,6 +188,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
_ => Animate::None,
}
}
/// `DyeColor.getMapColor` of a concrete powder colour.
fn concrete_powder_color(dye: &str) -> u32 {
match dye {
"white" => 0xFFFFFF,
"orange" => 0xD87F33,
"magenta" => 0xB24CD8,
"light_blue" => 0x6699D8,
"yellow" => 0xE5E533,
"lime" => 0x7FCC19,
"pink" => 0xF27FA5,
"gray" => 0x4C4C4C,
"light_gray" => 0x999999,
"cyan" => 0x4C7F99,
"purple" => 0x7F3FB2,
"blue" => 0x334CB2,
"brown" => 0x664C33,
"green" => 0x667F33,
"red" => 0x993333,
_ => 0x191919,
}
}
/// `RedstoneWireBlock.COLORS`.
fn redstone_color(power: u8) -> [f32; 3] {
let f = f32::from(power) / 15.0;
@@ -194,6 +242,10 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_logs: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
@@ -269,6 +321,8 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
burnable,
ambient,
})
@@ -357,6 +411,10 @@ impl AnimateContext<'_> {
fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add(&ParticleOptions::simple(ty), p, v);
}
/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then.
fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
}
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
engine.sounds.push((name, p, volume, pitch));
}
@@ -504,6 +562,53 @@ fn try_spawn_drip(
}
}
}
/// `BeehiveBlock.trySpawnDripParticles`.
fn beehive_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 {
return;
}
let Some((bottom, top, bounds)) = ctx.shape_y(pos) else {
return;
};
if top < 1.0
|| tables
.impermeable
.get(state.0 as usize)
.copied()
.unwrap_or(false)
{
return;
}
let y = if bottom > 0.0 {
f64::from(pos[1]) + bottom - 0.05
} else {
let below = [pos[0], pos[1] - 1, pos[2]];
let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1);
// `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below.
if (below_top < 1.0 || !ctx.sturdy(below))
&& ctx.facts.get(ctx.state(below)).fluid == Fluid::None
{
f64::from(pos[1]) - 0.05
} else {
return;
}
};
let r = engine.random();
let (a, b) = (r.next_double(), r.next_double());
let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2]));
let p = [
fx + bounds[0] + (bounds[1] - bounds[0]) * a,
y,
fz + bounds[2] + (bounds[3] - bounds[2]) * b,
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -685,7 +790,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling) => {
Animate::Leaves(falling, fixed_tint, poplar) => {
let below = [pos[0], pos[1] - 1, pos[2]];
// LeavesBlock.makeDrippingWaterParticles.
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
@@ -697,6 +802,9 @@ fn block_animate_tick(
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
if poplar {
leaves_ambient_sound(tables, engine, ctx, state, pos);
}
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
if let Some((ty, chance)) = falling
&& engine.random().next_float() < chance
@@ -705,7 +813,7 @@ fn block_animate_tick(
let r = engine.random();
let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()];
let options = if ty == T::TintedLeaves {
let c = ctx.foliage(pos);
let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos));
let f = |s: u32| ((c >> s) & 255) as f32 / 255.0;
ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0])
} else {
@@ -869,7 +977,7 @@ fn block_animate_tick(
fy + 1.0 + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::SulfurBubbles, p, [0.0; 3]);
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
}
if next_int(engine.random(), 10) == 0 {
let name = "minecraft:block.potent_sulfur.noxious_gas";
@@ -877,6 +985,36 @@ fn block_animate_tick(
}
}
}
Animate::WetSponge => {
// Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST.
const DIRECTIONS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
let d = DIRECTIONS[next_int(engine.random(), 6) as usize];
// The sponge occludes, so only a sturdy neighbour face hides the drip.
if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) {
let r = engine.random();
let p = match d {
[0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()],
[dx, _, 0] => {
let y = fy + r.next_double() * 0.8;
let z = fz + r.next_double();
[fx + if dx > 0 { 1.1 } else { 0.05 }, y, z]
}
[_, _, dz] => {
let y = fy + r.next_double() * 0.8;
let x = fx + r.next_double();
[x, y, fz + if dz > 0 { 1.1 } else { 0.05 }]
}
};
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
Animate::Mycelium => {
let r = engine.random();
if next_int(r, 10) == 0 {
@@ -959,7 +1097,7 @@ fn block_animate_tick(
add(engine, T::Portal, p, v);
}
}
Animate::Falling(color) => {
Animate::Falling(color, sand) => {
if next_int(engine.random(), 16) == 0 {
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
// FallingBlock.isFree: air, fire, liquids or replaceable.
@@ -976,6 +1114,21 @@ fn block_animate_tick(
);
}
}
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
if sand
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
&& next_int(engine.random(), 2100) == 0
&& sand_sound_nearby(tables, ctx, pos)
{
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
let attempts = next_int(engine.random(), 1) + 1;
for _ in 0..attempts {
beehive_drip(tables, engine, ctx, state, pos);
}
}
Animate::RedstoneOre => {
for d in [
@@ -1086,7 +1239,7 @@ fn block_animate_tick(
}
Animate::BubbleColumn { drag } => {
if drag {
add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1099,7 +1252,7 @@ fn block_animate_tick(
);
}
} else {
add(
add_visible(
engine,
T::BubbleColumnUp,
[fx + 0.5, fy, fz + 0.5],
@@ -1111,7 +1264,7 @@ fn block_animate_tick(
fy + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1189,6 +1342,91 @@ fn block_animate_tick(
}
}
}
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
/// one log and three leaves of the same kind around the block.
fn leaves_ambient_sound(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if next_int(engine.random(), 300) != 0 {
return;
}
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
return;
};
let (mut logs, mut leaves) = (0, 0);
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
if tables
.poplar_leaf_logs
.get(neighbor.0 as usize)
.copied()
.unwrap_or(false)
{
logs += 1;
}
if ctx
.registry
.state(neighbor)
.is_some_and(|s| &s.name == name)
{
leaves += 1;
}
if logs == 1 && leaves == 3 {
let p = pos.map(f64::from);
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
return;
}
}
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
let is_sand = |p: [i32; 3]| {
tables
.desert_sand_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let (mut found, mut checked) = (0, 0);
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
// columnContainsTriggeringBlock.
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
let matches = if (surface - pos[1]).abs() > 5 {
let mut y = pos[1] + 6;
let mut above_air = ctx.is_air([x, y, z]);
y -= 1;
let mut hit = false;
for _ in 0..10 {
if above_air && is_sand([x, y, z]) {
hit = true;
break;
}
above_air = ctx.is_air([x, y, z]);
y -= 1;
}
hit
} else {
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
};
if matches {
found += 1;
if found > 3 {
return true;
}
}
checked += 1;
if 4 - checked + found < 3 {
return false;
}
}
false
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
@@ -1209,21 +1447,32 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
}
add(engine, T::SmallFlame, p, [0.0; 3]);
}
/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items).
/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says
/// which of a campfire's four slots hold an item.
pub fn campfire_particles<'a>(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
cooking: &dyn Fn([i32; 3]) -> [bool; 4],
) {
for &(pos, state) in blocks {
if !tables.is_lit_campfire(state) {
continue;
}
let signal = ctx
let properties = ctx
.registry
.state(BlockStateId(state))
.is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true"));
.map(|s| &s.properties);
let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str);
let signal = prop("signal_fire") == Some("true");
// Direction.get2DDataValue: south, west, north, east.
let rotation = match prop("facing") {
Some("west") => 1,
Some("north") => 2,
Some("east") => 3,
_ => 0,
};
let r = engine.random();
if r.next_float() < 0.11 {
let count = next_int(r, 2) + 2;
@@ -1231,6 +1480,28 @@ pub fn campfire_particles<'a>(
make_campfire_smoke(engine, pos, signal, false);
}
}
// The smoke over each cooking item, at the slot's corner of the fire.
let occupied = cooking(pos);
for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) {
if engine.random().next_float() >= 0.2 {
continue;
}
// Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side.
let (step, clockwise) = match (slot + rotation) % 4 {
0 => ([0.0, 1.0], [-1.0, 0.0]),
1 => ([-1.0, 0.0], [0.0, -1.0]),
2 => ([0.0, -1.0], [1.0, 0.0]),
_ => ([1.0, 0.0], [0.0, 1.0]),
};
let at = [
f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125,
];
for _ in 0..4 {
add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]);
}
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
+47 -4
View File
@@ -9,7 +9,7 @@ use crate::{
collisions::SceneCollisions,
mobs::Who,
motion::{FluidResources, LAVA, WATER},
projectiles::{Hit, Projectile},
projectiles::{Hit, Projectile, ProjectileKind},
world::World,
};
use horizon_core::{
@@ -132,6 +132,10 @@ pub enum ArrowSound {
HitGround,
/// `entity.generic.extinguish_fire`.
Extinguish,
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
TridentHitGround,
/// `ThrownTrident.onHitEntity`'s `item.trident.hit`.
TridentHit,
}
/// What a burning arrow does to the block it hits (`onProjectileHit`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -169,6 +173,9 @@ pub struct ArrowState {
pub was_eye_in_water: bool,
pub first_tick: bool,
pub has_been_shot: bool,
/// `ThrownTrident.dealtDamage`: it has hit an entity (or sat in a block
/// for over 4 ticks) and hits no more.
pub dealt_damage: bool,
pub removed: bool,
/// The arrow's own random (`Entity.random`).
pub random: LegacyRandom,
@@ -211,6 +218,7 @@ impl ArrowState {
was_eye_in_water: false,
first_tick: true,
has_been_shot: false,
dealt_damage: false,
removed: false,
random,
events: Vec::new(),
@@ -451,12 +459,14 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
let Some(mut st) = p.arrow.take() else {
return false;
};
let trident = matches!(p.kind, ProjectileKind::Trident);
let mut ticker = Ticker {
p,
st: &mut st,
scene,
};
match outcome {
EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(),
EntityHit::Hurt => {
ticker.hit_sound();
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
@@ -501,7 +511,16 @@ impl Ticker<'_, '_, '_, '_> {
p.z,
))
}
/// Whether this is a `ThrownTrident` (`AbstractArrow` with its own water
/// inertia, sounds and hit).
fn trident(&self) -> bool {
matches!(self.p.kind, ProjectileKind::Trident)
}
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
// ThrownTrident.tick.
if self.trident() && self.st.in_ground_time > 4 {
self.st.dealt_damage = true;
}
let physics = !self.st.no_physics;
let movement = self.p.velocity;
let block_pos = self.block_position();
@@ -549,8 +568,9 @@ impl Ticker<'_, '_, '_, '_> {
self.st.in_ground_time = 0;
let original = self.position();
if self.st.was_touching_water {
// getWaterInertia, then the bubbles behind it.
self.p.velocity = self.p.velocity.scale(f64::from(0.6f32));
// getWaterInertia (0.99 for a trident), then the bubbles behind it.
let inertia = if self.trident() { 0.99f32 } else { 0.6f32 };
self.p.velocity = self.p.velocity.scale(f64::from(inertia));
let v = self.p.velocity;
for _ in 0..4 {
self.st.events.push(ArrowEvent::Particle {
@@ -687,6 +707,8 @@ impl Ticker<'_, '_, '_, '_> {
.expand_towards(self.p.velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
let mut best: Option<(Who, Vec3, f64)> = None;
// ThrownTrident.findHitEntity: none once it has dealt damage.
let targets = if self.st.dealt_damage { &[] } else { targets };
for (who, b) in targets {
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
continue;
@@ -757,12 +779,33 @@ impl Ticker<'_, '_, '_, '_> {
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
let sound = if self.trident() {
ArrowSound::TridentHitGround
} else {
ArrowSound::HitGround
};
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::HitGround,
sound,
volume: 1.0,
pitch,
});
}
/// `ThrownTrident.onHitEntity` after the hurt, whether it landed or not:
/// the hit sound, then `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`. The
/// trident keeps flying (it has dealt its damage) and falls.
fn trident_bounce(&mut self) {
self.st.dealt_damage = true;
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::TridentHit,
volume: 1.0,
pitch: 1.0,
});
// ProjectileDeflection.REVERSE.
let rotation = 170.0 + self.st.random.next_float() * 20.0;
let v = self.p.velocity;
self.p.velocity = Vec3::new(v.x * (-0.02 * 0.5), v.y * (-0.2 * 0.5), v.z * (-0.02 * 0.5));
self.p.yaw += rotation;
}
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
/// a half turn give or take ten degrees at a tenth of the speed; one
/// left almost still drops its pickup (ALLOWED) and is discarded.
+1 -1
View File
@@ -3,8 +3,8 @@
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
//! against the resource tree; a later source replaces an earlier sprite of
//! the same id and a filter removes the matching ones.
use crate::packs::pattern::Pattern;
use horizon_assets::palette::apply_palette;
use horizon_client_core::pattern::Pattern;
use serde_json::Value;
use std::{
collections::BTreeMap,
-5
View File
@@ -719,11 +719,6 @@ impl Audio {
self.play(event, at, volume, pitch);
self.capture = capture;
}
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
/// client (the entity's own random there, never the server's world random).
pub fn hurt_pitch(&mut self) -> f32 {
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
}
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
pub fn play_button_click(&mut self) {
self.play_instance(
+1 -1
View File
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
}
/// The discovery document, fetched once per game (a failure is retried next time).
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
if let Some(d) = CACHE.lock().unwrap().clone() {
return Ok(d);
+48
View File
@@ -450,6 +450,54 @@ mod tests {
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
/// The status a fake control answers with: the player is where it should
/// be, nothing is queued, and the world holds `chunks` resident chunks.
fn status(chunks: u64) -> String {
serde_json::json!({
"player": {"position": [0.0, 64.0, 0.0]},
"pending_chunks": 0,
"screen": "playing",
"render_distance": 12,
"chunks": chunks,
"generated_chunks": chunks,
"meshed_sections": 0,
"streaming": {},
})
.to_string()
}
#[test]
fn the_load_waits_for_the_chunks_a_teleport_starts() {
// Right after a teleport the game reports steady counts and nothing
// queued for over a second, before the integrated server has sent the
// first chunk: the quiet rule alone would call that loaded, and the
// scene would be measured over an empty view (#1749).
const LATE: usize = 30;
let (tx, rx) = mpsc::channel::<Request>();
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = std::sync::Arc::clone(&polls);
let server = thread::spawn(move || {
let mut seen = 0;
while let Ok(request) = rx.recv() {
if request.line == "status" {
let chunks = if seen < LATE { 0 } else { 400 };
seen += 1;
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
let _ = request.reply.send(status(chunks));
} else {
let _ = request.reply.send("{}".to_owned());
}
}
});
let control = Control(tx);
wait_loaded(&control, |_| true).expect("the scene loads");
assert!(
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
"the load finished before the chunks arrived"
);
drop(control);
let _ = server.join();
}
#[test]
fn yaw_matches_vanilla() {
+302
View File
@@ -0,0 +1,302 @@
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
//! `Language.getVisualOrder`): the logical text a player types becomes the
//! glyphs a font draws left to right. Arabic letters are shaped into their
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
//! text is reordered by the Unicode bidirectional algorithm, and characters in
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
use crate::text::{Span, Text};
use std::borrow::Cow;
use unicode_bidi::{
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
};
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
/// (diacritics) and `None` never joins.
#[derive(Clone, Copy, PartialEq)]
enum Join {
None,
Right,
Dual,
Mark,
}
use Join::{Dual, Mark, Right};
/// ICU's joining table (`araLink`): code point ranges and their class, with the
/// lam-alef ligatures at the end. Anything else is `Join::None`.
const JOINS: &[(u32, u32, Join)] = &[
(0x0622, 0x0625, Right),
(0x0626, 0x0626, Dual),
(0x0627, 0x0627, Right),
(0x0628, 0x0628, Dual),
(0x0629, 0x0629, Right),
(0x062A, 0x062E, Dual),
(0x062F, 0x0632, Right),
(0x0633, 0x063A, Dual),
(0x0640, 0x0647, Dual),
(0x0648, 0x0649, Right),
(0x064A, 0x064A, Dual),
(0x064B, 0x0656, Mark),
(0x0670, 0x0670, Mark),
(0x0671, 0x0673, Right),
(0x0675, 0x0677, Right),
(0x0678, 0x0687, Dual),
(0x0688, 0x0699, Right),
(0x069A, 0x06B9, Dual),
(0x06BA, 0x06BA, Right),
(0x06BB, 0x06BF, Dual),
(0x06C0, 0x06C0, Right),
(0x06C1, 0x06C1, Dual),
(0x06C2, 0x06CB, Right),
(0x06CC, 0x06CC, Dual),
(0x06CD, 0x06CD, Right),
(0x06CE, 0x06D1, Dual),
(0x06D2, 0x06D3, Right),
(0x200D, 0x200D, Dual),
(0x206D, 0x206F, Mark),
(0xFEF5, 0xFEFC, Right),
];
/// Letters that have joined forms and the code point of the isolated one; the
/// final, initial and medial forms follow it (a right-joining letter has the
/// isolated and final only). The lam-alef ligatures are their own letters.
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
/// U+06D3) unchanged, so they are in `JOINS` but not here.
#[rustfmt::skip]
const FORMS: &[(u32, u32)] = &[
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
];
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
/// neighbouring characters that mirror each other.
const MIRRORED: &str = "\
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
fn join(c: char) -> Join {
let c = c as u32;
JOINS
.iter()
.find(|&&(from, to, _)| (from..=to).contains(&c))
.map_or(Join::None, |&(_, _, join)| join)
}
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
fn letter_of(c: char) -> char {
let c32 = c as u32;
if !matches!(c32, 0xFE70..=0xFEFC) {
return c;
}
if (0xFE70..0xFE80).contains(&c32) {
// The diacritics' forms pair up from fathatan on.
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
}
FORMS
.iter()
.find(|&&(letter, iso)| {
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
4
} else {
2
};
(iso..iso + forms).contains(&c32)
})
.and_then(|&(letter, _)| char::from_u32(letter))
.unwrap_or(c)
}
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
/// may sit between) becomes one ligature, then every letter takes the isolated,
/// final, initial or medial form its neighbours call for. Diacritics take the
/// tatweel form between two joined letters. The text can only get shorter.
fn shape(text: &[char]) -> Vec<char> {
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
let mut chars = Vec::with_capacity(letters.len());
let mut i = 0;
while i < letters.len() {
if letters[i] == '\u{644}' {
let mut at = i + 1;
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
at += 1;
}
let alef = letters.get(at).and_then(|&c| {
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
.iter()
.position(|&a| a == c)
});
if let Some(alef) = alef {
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
chars.extend_from_slice(&letters[i + 1..at]);
i = at + 1;
continue;
}
}
chars.push(letters[i]);
i += 1;
}
// Whether the nearest letter before joins the next one, and the joins of
// the nearest letter after.
let mut joins_next = false;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let next_joins = chars[i + 1..]
.iter()
.map(|&n| join(n))
.find(|&j| j != Mark)
.is_some_and(|j| j != Join::None);
let kind = join(c);
let shaped = match kind {
Join::None => {
joins_next = false;
c
}
Mark => match c as u32 {
m @ 0x064B..=0x0652 => {
let iso = 0xFE70 + 2 * (m - 0x064B);
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
}
_ => c,
},
Right | Dual => {
let to_next = kind == Dual && next_joins;
let form = match (joins_next, to_next) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
joins_next = kind == Dual;
FORMS
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
.ok()
.and_then(|at| char::from_u32(FORMS[at].1 + form))
.unwrap_or(c)
}
};
out.push(shaped);
}
out
}
/// `UCharacter.getMirror`.
fn mirror(c: char) -> char {
if bidi_class(c) != BidiClass::ON {
return c;
}
let mut pairs = MIRRORED.chars();
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
if c == a {
return b;
}
if c == b {
return a;
}
}
c
}
/// Whether a character can change anything: right-to-left letters and numbers,
/// the explicit direction controls and the diacritics that shaping turns into
/// letters. Without any, left-to-right text keeps its order.
fn directional(c: char) -> bool {
c as u32 >= 0x0590
&& (matches!(c as u32, 0x064B..=0x0652)
|| matches!(
bidi_class(c),
BidiClass::R
| BidiClass::AL
| BidiClass::AN
| BidiClass::LRE
| BidiClass::RLE
| BidiClass::LRO
| BidiClass::RLO
| BidiClass::PDF
| BidiClass::LRI
| BidiClass::RLI
| BidiClass::FSI
| BidiClass::PDI
))
}
/// Whether `chars` keep their order and shapes: left-to-right text without
/// anything directional, in a language that reads left to right by default.
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
!default_rtl && !chars.any(directional)
}
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
/// character's position in the shaped text. A character's style is the one at
/// its position in the shaped text, as `SubStringSource` takes it (the text
/// shrinks at a lam-alef ligature).
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
let shaped = shape(chars);
let plain: String = shaped.iter().collect();
// DEFAULT_RTL only changes text without a strong character.
let level =
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
let info = ParagraphBidiInfo::new(&plain, level);
let levels = info.reordered_levels_per_char(0..plain.len());
ParagraphBidiInfo::reorder_visual(&levels)
.into_iter()
.map(|i| {
let c = if levels[i].is_rtl() {
mirror(shaped[i])
} else {
shaped[i]
};
(c, i)
})
.collect()
}
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
/// the current language's direction.
pub fn visual(text: &Text) -> Cow<'_, Text> {
let default_rtl = crate::lang::bidirectional();
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
return Cow::Borrowed(text);
}
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
let mut out = Text::default();
for (c, at) in reorder(&chars, default_rtl) {
match out.spans.last_mut() {
Some(span) if span.style == *styles[at] => span.text.push(c),
_ => out.spans.push(Span {
text: c.to_string(),
style: styles[at].clone(),
}),
}
}
Cow::Owned(out)
}
/// The same for a plain string.
pub fn visual_str(s: &str) -> Cow<'_, str> {
let default_rtl = crate::lang::bidirectional();
if unchanged(s.chars(), default_rtl) {
return Cow::Borrowed(s);
}
let chars: Vec<char> = s.chars().collect();
Cow::Owned(
reorder(&chars, default_rtl)
.into_iter()
.map(|(c, _)| c)
.collect(),
)
}
@@ -149,6 +149,8 @@ pub fn draw_part(
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -610,7 +612,9 @@ fn sign_text_calls(
.iter()
.enumerate()
.map(|(i, line)| {
// The line that fits, in the order it is drawn (getVisualOrder).
let shown = first_line(line, data.max_line_width(), &advance);
let shown = crate::bidi::visual_str(&shown).into_owned();
TextCall {
matrix: m,
x: -((width(&shown) as i32) / 2) as f32,
@@ -648,6 +652,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
layer: 0,
color: c,
light: call.light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -734,7 +740,7 @@ pub struct Scene<'a> {
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
}
@@ -1286,11 +1292,12 @@ impl BlockEntityRenderer {
};
// SkullBlockRenderer.getRenderType: the profile's skin when it
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
let texture = if *kind == SkullType::Player {
(scene.skull)(pos).unwrap_or(texture)
let skin = if *kind == SkullType::Player {
(scene.skull)(pos)
} else {
texture
None
};
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
@@ -1714,6 +1721,8 @@ impl BeamPart {
layer: 6,
color,
light: self.wrap,
glint_uv: [0.0, 0.0],
});
}
}
@@ -1784,6 +1793,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
layer,
color: 0,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ pub const MAP_COLORS: [u32; 62] = [
14200723, 8365974,
];
/// The 12 edges of the unit cube (outline data predating the export).
fn cube_edges() -> Vec<[f64; 6]> {
pub fn cube_edges() -> Vec<[f64; 6]> {
let mut edges = Vec::new();
for a in 0..3 {
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
+2
View File
@@ -49,6 +49,8 @@ pub fn geometry(
color: 0xffff_ffff,
light: stage_light(stage),
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
}
+4 -226
View File
@@ -9,232 +9,10 @@ use crate::{
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
Beneficial,
Harmful,
Neutral,
}
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
pub color: u32,
pub category: Category,
pub instant: bool,
}
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
EffectDef {
id,
color,
category,
instant: false,
}
}
use Category::{Beneficial as B, Harmful as H, Neutral as N};
/// `MobEffects` in registration order.
pub const EFFECTS: &[EffectDef] = &[
fx("speed", 3402751, B),
fx("slowness", 9154528, H),
fx("haste", 14270531, B),
fx("mining_fatigue", 4866583, H),
fx("strength", 16762624, B),
EffectDef {
id: "instant_health",
color: 16262179,
category: B,
instant: true,
},
EffectDef {
id: "instant_damage",
color: 11101546,
category: H,
instant: true,
},
fx("jump_boost", 16646020, B),
fx("nausea", 5578058, H),
fx("regeneration", 13458603, B),
fx("resistance", 9520880, B),
fx("fire_resistance", 16750848, B),
fx("water_breathing", 10017472, B),
fx("invisibility", 16185078, B),
fx("blindness", 2039587, H),
fx("night_vision", 12779366, B),
fx("hunger", 5797459, H),
fx("weakness", 4738376, H),
fx("poison", 8889187, H),
fx("wither", 7561558, H),
fx("health_boost", 16284963, B),
fx("absorption", 2445989, B),
EffectDef {
id: "saturation",
color: 16262179,
category: B,
instant: true,
},
fx("glowing", 9740385, N),
fx("levitation", 13565951, H),
fx("luck", 5882118, B),
fx("unluck", 12624973, H),
fx("slow_falling", 15978425, B),
fx("conduit_power", 1950417, B),
fx("dolphins_grace", 8954814, B),
fx("bad_omen", 745784, N),
fx("hero_of_the_village", 4521796, B),
fx("darkness", 2696993, H),
fx("trial_omen", 1484454, N),
fx("raid_omen", 14565464, N),
fx("wind_charged", 12438015, H),
fx("weaving", 7891290, H),
fx("oozing", 10092451, H),
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
pub struct PotionDef {
pub id: &'static str,
pub name: &'static str,
pub effects: &'static [(&'static str, i32, i32)],
}
const fn p(
id: &'static str,
name: &'static str,
effects: &'static [(&'static str, i32, i32)],
) -> PotionDef {
PotionDef { id, name, effects }
}
/// `Potions` in registration order.
pub const POTIONS: &[PotionDef] = &[
p("water", "water", &[]),
p("mundane", "mundane", &[]),
p("thick", "thick", &[]),
p("awkward", "awkward", &[]),
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
p(
"long_night_vision",
"night_vision",
&[("night_vision", 9600, 0)],
),
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
p(
"long_invisibility",
"invisibility",
&[("invisibility", 9600, 0)],
),
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
p(
"fire_resistance",
"fire_resistance",
&[("fire_resistance", 3600, 0)],
),
p(
"long_fire_resistance",
"fire_resistance",
&[("fire_resistance", 9600, 0)],
),
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
p("slowness", "slowness", &[("slowness", 1800, 0)]),
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
p(
"turtle_master",
"turtle_master",
&[("slowness", 400, 3), ("resistance", 400, 2)],
),
p(
"long_turtle_master",
"turtle_master",
&[("slowness", 800, 3), ("resistance", 800, 2)],
),
p(
"strong_turtle_master",
"turtle_master",
&[("slowness", 400, 5), ("resistance", 400, 3)],
),
p(
"water_breathing",
"water_breathing",
&[("water_breathing", 3600, 0)],
),
p(
"long_water_breathing",
"water_breathing",
&[("water_breathing", 9600, 0)],
),
p("healing", "healing", &[("instant_health", 1, 0)]),
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
p("harming", "harming", &[("instant_damage", 1, 0)]),
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
p("poison", "poison", &[("poison", 900, 0)]),
p("long_poison", "poison", &[("poison", 1800, 0)]),
p("strong_poison", "poison", &[("poison", 432, 1)]),
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
p(
"long_regeneration",
"regeneration",
&[("regeneration", 1800, 0)],
),
p(
"strong_regeneration",
"regeneration",
&[("regeneration", 450, 1)],
),
p("strength", "strength", &[("strength", 3600, 0)]),
p("long_strength", "strength", &[("strength", 9600, 0)]),
p("strong_strength", "strength", &[("strength", 1800, 1)]),
p("weakness", "weakness", &[("weakness", 1800, 0)]),
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
p("luck", "luck", &[("luck", 6000, 0)]),
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
p(
"long_slow_falling",
"slow_falling",
&[("slow_falling", 4800, 0)],
),
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
p("weaving", "weaving", &[("weaving", 3600, 0)]),
p("oozing", "oozing", &[("oozing", 3600, 0)]),
p("infested", "infested", &[("infested", 3600, 0)]),
];
pub fn potion(id: &str) -> Option<&'static PotionDef> {
let id = id.trim_start_matches("minecraft:");
POTIONS.iter().find(|p| p.id == id)
}
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, _, amp) in def.effects {
let Some(e) = effect(e) else { continue };
let w = *amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
total += w;
}
match (
r.checked_div(total),
g.checked_div(total),
b.checked_div(total),
) {
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
_ => BASE_POTION_COLOR,
}
}
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
potion, potion_color,
};
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
pub fn format_duration(ticks: i32) -> String {
let secs = ticks / 20;
+48 -8
View File
@@ -1,6 +1,7 @@
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
//! overrides on it.
use crate::item_data::BundleSelected;
use crate::items::{ItemStack, Items};
use horizon_world::nbt::Tag;
@@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
pub struct Contents {
items: Vec<ItemStack>,
weight: Weight,
/// `selectedItem`: an index into `items`, -1 for none.
selected: i32,
}
impl Contents {
@@ -104,9 +107,11 @@ impl Contents {
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
let inside = contents_of(items, stack)?.to_vec();
let weight = contents_weight(items, &inside);
let selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
Some(Self {
items: inside,
weight,
selected,
})
}
@@ -154,18 +159,26 @@ impl Contents {
amount
}
/// `removeOne`: the first stack (Horizon keeps no selected item).
/// `removeOne`: the selected stack, else the first; nothing is selected after.
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
if self.items.is_empty() {
return None;
}
let stack = self.items.remove(0);
let at =
usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 });
let stack = self.items.remove(at);
self.weight = self
.weight
.sub(item_weight(items, &stack).times(stack.count));
self.selected = -1;
Some(stack)
}
/// `getSelectedItemIndex`.
pub fn selected(&self) -> i32 {
self.selected
}
/// The stacks inside, in display order (the first is the next out).
pub fn items(&self) -> &[ItemStack] {
&self.items
@@ -200,11 +213,28 @@ impl Contents {
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
let data = bundle.data_mut();
data.bundle_contents = self.items;
data.bundle_selected = BundleSelected(self.selected);
bundle.tidy();
}
}
/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the
/// selected one already or outside the contents.
pub fn toggle_selected(bundle: &mut ItemStack, index: i32) {
let Some(data) = bundle.extra.as_deref_mut() else {
return;
};
let inside = (0..data.bundle_contents.len() as i32).contains(&index);
let selected = if data.bundle_selected.0 != index && inside {
index
} else {
-1
};
data.bundle_selected = BundleSelected(selected);
}
#[cfg(test)]
mod tests {
use super::Weight;
@@ -232,8 +262,10 @@ pub struct TooltipLayout {
/// The component's own width (96) and height, the text's excluded.
pub width: f32,
pub height: f32,
/// Each slot's top-left and the stack in it, in reading order.
/// Each slot's top-left and the stack in it, in drawing order.
pub slots: Vec<([f32; 2], ItemStack)>,
/// The position in `slots` of the selected item, which is drawn highlighted.
pub selected: Option<usize>,
/// The `+N` of the items past the grid: its top-left and the count.
pub overflow: Option<([f32; 2], u32)>,
/// The progress bar's top-left, its fill's width out of 94, and whether
@@ -266,6 +298,7 @@ pub fn tooltip_layout(
width: 96.0,
height: description_height + 13.0 + 8.0,
slots: Vec::new(),
selected: None,
overflow: None,
bar: [x + offset, y + description_height + 4.0],
fill: 0.0,
@@ -282,6 +315,7 @@ pub fn tooltip_layout(
let x_start = x + offset + 96.0;
let y_start = y + grid_height;
let mut slots = Vec::with_capacity(shown.len());
let mut selected = None;
let mut overflow = None;
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
let mut slot = 0;
@@ -290,8 +324,13 @@ pub fn tooltip_layout(
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
if hidden > 0 && row == 1 && column == 1 {
overflow = Some((at, hidden));
} else if let Some(stack) = shown.get(slot) {
slots.push((at, stack.clone()));
} else if slot < shown.len() {
// `extractSlot`: the cell of slot number n holds item `size - n`.
let index = shown.len() - 1 - slot;
if usize::try_from(contents.selected()) == Ok(index) {
selected = Some(slots.len());
}
slots.push((at, shown[index].clone()));
slot += 1;
}
}
@@ -301,6 +340,7 @@ pub fn tooltip_layout(
width: 96.0,
height: grid_height + 13.0 + 8.0,
slots,
selected,
overflow,
bar: [x + offset, y + grid_height + 4.0],
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
@@ -349,8 +389,8 @@ mod tooltip_tests {
assert_eq!(layout.slots.len(), 12);
assert_eq!(layout.overflow, None);
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
// Rows fill bottom-up and columns right-to-left: the first item is in
// the bottom-right cell, the twelfth in the top-left.
// Rows fill bottom-up and columns right-to-left: the twelfth item is in
// the bottom-right cell, the first in the top-left.
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
+23 -8
View File
@@ -40,6 +40,8 @@ pub struct Interaction {
/// The cauldron's new state.
pub block: Option<BlockState>,
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -57,6 +59,7 @@ fn pass() -> Interaction {
outcome: Outcome::Pass,
block: None,
sound: None,
stat: None,
}
}
@@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState {
}
}
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
Interaction {
outcome: Outcome::Success,
block: Some(block),
sound,
stat: Some(stat),
}
}
@@ -179,18 +183,22 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None);
return changed(lowered(cauldron), None, "clean_armor");
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
return pass();
}
filled(player, ItemStack::new(new, 1), true);
changed(state("minecraft:cauldron", None), Some(sound))
changed(
state("minecraft:cauldron", None),
Some(sound),
"use_cauldron",
)
};
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
filled(player, ItemStack::new("minecraft:bucket", 1), true);
changed(block, Some(sound))
changed(block, Some(sound), "fill_cauldron")
};
match item {
"minecraft:lava_bucket" => {
@@ -199,6 +207,7 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -221,6 +230,7 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -241,6 +251,7 @@ pub fn interact(
changed(
state("minecraft:water_cauldron", Some(1)),
Some("minecraft:item.bottle.empty"),
"use_cauldron",
)
}
("water", "minecraft:bucket") => fill_bucket(
@@ -255,7 +266,11 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -266,7 +281,7 @@ pub fn interact(
// BlockState.cycle on a 1..=3 property
next.properties
.insert("level".into(), (level % 3 + 1).to_string());
changed(next, Some("minecraft:item.bottle.empty"))
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
}
("water", name)
if name
@@ -283,7 +298,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None)
changed(lowered(cauldron), None, "clean_banner")
}
("water", name)
if name
@@ -292,7 +307,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None)
changed(lowered(cauldron), None, "clean_shulker_box")
}
("lava", "minecraft:bucket") => fill_bucket(
player,
+99 -1
View File
@@ -16,6 +16,9 @@ use crate::{
#[path = "chat_tests.rs"]
mod tests;
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
@@ -80,6 +83,10 @@ impl ChatAbilities {
ChatSource::Client => true,
}
}
/// `ChatAbilities.hasAnyRestrictions`: the Chat option takes something.
pub fn any_restriction(&self) -> bool {
*self != Self::new(ChatVisibility::Full)
}
/// Whether the chat screen may send `input` (`hasAllowedInput`).
pub fn allows(&self, input: &str) -> bool {
if input.starts_with('/') {
@@ -185,6 +192,10 @@ pub struct Chat {
lines: Vec<Line>,
/// Sent messages and commands (ChatComponent.recentChat).
pub recent: Vec<String>,
/// `CommandHistory`: the last commands, kept in `command_history.txt`
/// in the game folder once `load_history` has set it.
commands: Vec<String>,
history_file: Option<std::path::PathBuf>,
scroll: usize,
new_since_scroll: bool,
pub options: ChatOptions,
@@ -310,6 +321,17 @@ impl Chat {
self.lines.clear();
self.scroll = 0;
}
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
/// start the chat's history (`ChatComponent`'s constructor).
pub fn load_history(&mut self, game_folder: &std::path::Path) {
let file = game_folder.join(COMMAND_HISTORY_FILE);
if let Ok(text) = std::fs::read_to_string(&file) {
self.commands = text.lines().map(str::to_owned).collect();
self.recent.extend(self.commands.iter().cloned());
}
self.history_file = Some(file);
}
/// `ChatComponent.addRecentChat`: a command also goes to the saved history.
pub fn add_recent(&mut self, message: &str) {
if self.recent.last().map(String::as_str) != Some(message) {
if self.recent.len() >= 100 {
@@ -317,6 +339,21 @@ impl Chat {
}
self.recent.push(message.to_owned());
}
if message.starts_with('/') && self.commands.last().map(String::as_str) != Some(message) {
if self.commands.len() >= MAX_PERSISTED_COMMAND_HISTORY {
self.commands.remove(0);
}
self.commands.push(message.to_owned());
if let Some(file) = &self.history_file {
let mut text = self.commands.join("\n");
text.push('\n');
if let Err(e) = std::fs::write(file, text) {
log::error!(
"Failed to write {COMMAND_HISTORY_FILE}, command history will be missing: {e}"
);
}
}
}
}
pub fn reset_scroll(&mut self) {
self.scroll = 0;
@@ -438,10 +475,71 @@ impl Chat {
out.push(GuiDraw::Pop);
out
}
/// Draws [`Chat::layout`] into the HUD batch.
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
hud_layout::paint(batch, atlas, &prompt);
}
}
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
/// background row and the red, underlined "Chat is restricted" line in
/// the row above the messages, clipped to the chat's width.
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
let o = &self.options;
let scale = o.scale as f32;
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
let shown = self
.lines
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let top = chat_bottom - (shown as i32 + 1) * entry_height;
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
color: Some(text::RED),
underlined: Some(true),
..Style::default()
});
// ComponentRenderUtils.clipText.
let mut width = 0.0;
let fits = prompt
.chars()
.take_while(|(c, style)| {
width += font.advance(*c, style);
width <= max_width as f32
})
.count();
if fits < prompt.chars().count() {
prompt = prompt.slice(0, fits);
}
vec![
GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
]),
hud_layout::fill(
-2,
top,
max_width + 4 + 4,
top + entry_height,
hud_layout::as8(o.background_opacity as f32) << 24,
),
GuiDraw::Text {
text: prompt,
x: 0,
y: top + entry_height - bottom_to_text - 1,
color: hud_layout::white(o.opacity as f32 * 0.9 + 0.1),
shadow: true,
},
GuiDraw::Pop,
]
}
/// The click action under a GUI-space cursor while chat is focused.
pub fn click_at(
+72 -1
View File
@@ -2,6 +2,7 @@
//! threads can mesh and light immutable snapshots while the game thread edits
//! (copy-on-write through `Arc::make_mut`).
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::{Arc, OnceLock};
@@ -155,7 +156,7 @@ pub struct Chunk {
/// positions, applied once by LevelChunk.postProcessGeneration.
pub postprocess: Vec<[i32; 3]>,
/// Saved data Horizon keeps but does not simulate (save.rs).
pub saved: Option<Arc<crate::save::ChunkExtras>>,
pub saved: Option<Arc<ChunkExtras>>,
/// Entities read from a save (vanilla entity NBT), spawned on arrival.
pub entities: Vec<horizon_world::nbt::Tag>,
/// Structure starts reaching into this chunk (`structures.References`):
@@ -309,6 +310,76 @@ impl Chunk {
}
}
/// A proto chunk saved before it became final. Decoration writes into a
/// chunk's neighbours, and decorated chunks wait for theirs, so the chunks
/// past the finished ones hold writes that generating them again would lose
/// (whole structure pieces, trees, ores). Vanilla saves its proto chunks too.
pub struct SavedProto {
/// Blocks, scheduled ticks (in `saved`) and post-processing marks.
pub column: Chunk,
/// Decorated itself (`features`), not only written to by its neighbours
/// (`terrain`).
pub decorated: bool,
/// Block entity data in placement order, with `x`, `y` and `z`.
pub block_entities: Vec<Tag>,
/// Entities placed by structures, with absolute `Pos`.
pub entities: Vec<Tag>,
}
/// A container's loot table and what Horizon rolled from it. Vanilla keeps
/// `LootTable` and `LootTableSeed` until the container is first opened;
/// Horizon rolls when it generates or loads the chunk, and saves the table
/// again while the container still holds exactly the rolled items.
#[derive(Clone, Debug, PartialEq)]
pub struct RolledLoot {
pub pos: (i32, i32, i32),
pub table: String,
/// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable).
pub seed: Option<i64>,
/// The rolled `Items`.
pub items: Tag,
}
impl RolledLoot {
/// Puts the loot table back in place of `Items` while they are still the
/// roll; false once the container holds anything else.
pub fn restore(&self, map: &mut Compound) -> bool {
if map.get(&"Items".into()) != Some(&self.items) {
return false;
}
map.remove(&"Items".into());
map.insert("LootTable".into(), Tag::String(self.table.as_str().into()));
if let Some(seed) = self.seed.filter(|s| *s != 0) {
map.insert("LootTableSeed".into(), Tag::Long(seed));
}
true
}
}
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
/// Chunk root keys written back as loaded (structures, PostProcessing,
/// InhabitedTime, blending data, ...).
pub root: Compound,
/// Block entities Horizon does not model.
pub block_entities: Vec<Tag>,
/// The NBT of the modelled block entities as loaded, to restore the fields and
/// detail Horizon does not model when it saves them (`block_entity_nbt::overlay`).
pub block_entity_sources: Vec<((i32, i32, i32), Tag)>,
/// Every saved block entity's position in the order of the save's list,
/// the order `SerializableChunkData` puts them in the chunk's maps.
pub block_entity_order: Vec<(i32, i32, i32)>,
/// Scheduled ticks (`block_ticks`/`fluid_ticks`): delays relative to the
/// save time. Registered with the level's tick scheduler on arrival and
/// replaced with the scheduler's pending ticks when saving.
pub block_ticks: Vec<horizon_core::ticks::SavedTick>,
pub fluid_ticks: Vec<horizon_core::ticks::SavedTick>,
/// Game time written as `LastUpdate`.
pub last_update: i64,
/// Containers still holding their loot table's roll.
pub rolled_loot: Vec<RolledLoot>,
}
#[cfg(test)]
mod tests {
use super::*;
+11 -8
View File
@@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap {
}
}
impl crate::light::LightWorld for ChunkMap {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
self.columns.state(x, y, z)
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
self.columns.section(cx, s, cz)
}
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
self.columns.light(x, y, z)
}
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
self.columns.set_light(x, y, z, packed);
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
self.columns.set_section_light(cx, s, cz, light);
}
}
@@ -106,7 +103,13 @@ mod tests {
for column in map.values_mut() {
Arc::make_mut(column).light_loaded = true;
}
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
crate::light::LightWorld::set_section_light(
&mut map,
0,
0,
0,
crate::chunk::Light::Uniform(0),
);
assert_eq!(inserted, map.membership_revision());
map.remove(&(0, 0));
map.insert((1, 0), chunk);
+2
View File
@@ -179,6 +179,8 @@ impl Mesh {
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
+141 -5
View File
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
}
// Vanilla scopes this direction to Entity.move while a piston pushes it.
thread_local! {
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
}
/// Suppress the moving part of pistons moving in this direction during a push.
pub fn with_piston_noclip<T>(
direction: horizon_core::geometry::Direction,
f: impl FnOnce() -> T,
) -> T {
struct Reset(Option<horizon_core::geometry::Direction>);
impl Drop for Reset {
fn drop(&mut self) {
PISTON_NOCLIP.with(|value| value.set(self.0));
}
}
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
let _reset = Reset(previous);
f()
}
pub struct SceneCollisions<'w, 'r> {
pub world: &'w World,
pub binding: &'w BlockCollisionRegistry<'r>,
@@ -140,8 +161,21 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
/// `SceneCollisions::resolve` that also stops at the entity boxes.
impl<'a, W> EntityCollisions<'a, W>
where
W: BlockCollisionWorld<Error = io::Error> + EntityMovementWorld<Error = io::Error>,
{
/// `EntityCollisions` for a body the world border also stops: the border
/// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`).
pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self {
Self {
blocks,
entities: &[],
border: Some((border, at)),
}
}
/// `SceneCollisions::resolve` that also stops at the entity boxes and,
/// with a border, at the world border's walls.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = self.blocks.context(bounds);
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context,
context: CollisionContext::position(bounds.min_position().y),
bounds,
movement,
max_step,
@@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
return Ok(None);
};
pistons
.collision_shape(self, p, piston, None)
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
.map(Some)
.map_err(io::Error::other)
}
@@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
}
}
/// A collision world without blocks: no chunk is resident, so only the
/// world border stops a body.
pub struct EmptyCollisions;
impl BlockCollisionWorld for EmptyCollisions {
type Error = io::Error;
type Chunk = ChunkPos;
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(None)
}
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
Err(io::Error::other("no chunk is resident"))
}
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
Ok(false)
}
fn context_shape(
&mut self,
_: CollisionBlock,
_: BlockPos,
_: &CollisionContext<'_>,
) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
}
impl EntityMovementWorld for EmptyCollisions {
type Error = io::Error;
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
Ok(Vec::new())
}
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
/// Collision queries over any block lookup (test worlds without chunks):
/// every chunk is present, nothing is suffocating, no entities collide.
pub struct LookupCollisions<'w, 'r> {
@@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
Ok(false)
}
}
#[cfg(test)]
mod piston_noclip_tests {
use super::*;
use horizon_core::geometry::Direction;
#[test]
fn piston_noclip_is_scoped_and_restored_after_unwind() {
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
with_piston_noclip(Direction::East, || {
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
let _ =
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
});
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
}
}
#[cfg(test)]
mod border_movement_tests {
use super::*;
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
fn border() -> BorderGeometry {
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
}
/// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`:
/// a body inside close to the border collides with the border's walls.
#[test]
fn a_body_next_to_the_border_stops_at_its_wall() {
// A body 0.6 wide, 0.4 blocks short of the wall, moved east into it.
let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]);
let moved = collisions
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
// The wall stops the body 0.4 blocks short of the whole move.
assert!(
(moved.x - 0.4).abs() < 1.0e-9,
"the body was not stopped by the border: {moved:?}"
);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]);
let moved = collisions
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.51).abs() < 1.0e-9,
"the body did not move the whole way: {moved:?}"
);
}
}
@@ -35,6 +35,23 @@ impl Mode {
Self::Redstone => "minecraft:command_block",
}
}
/// `Enum.ordinal`, which `ServerboundSetCommandBlockPacket` writes.
pub fn ordinal(self) -> i32 {
match self {
Self::Sequence => 0,
Self::Auto => 1,
Self::Redstone => 2,
}
}
/// The mode of a packet's ordinal; `handleSetCommandBlock` treats any
/// other value as a plain command block (its `default` branch).
pub fn from_ordinal(ordinal: i32) -> Self {
match ordinal {
0 => Self::Sequence,
1 => Self::Auto,
_ => Self::Redstone,
}
}
/// `CycleButton`: the next value, wrapping round.
pub fn next(self) -> Self {
match self {
@@ -124,6 +124,18 @@ impl Clicks {
self.skip_next_release = true;
}
/// The screen that takes the pointer over starts with its own state:
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
/// belongs to on the screen, so a screen that opens (or closes) drops
/// them. A press whose release never arrived -- the pointer left the
/// window -- would otherwise keep the drag running, and `press` answers
/// `None` for every later press, so the quick move and the clicks go
/// dead on every screen after it.
pub fn cancel_drag(&mut self) {
self.quick_crafting = None;
self.skip_next_release = false;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
/// `target`, if any. A press holding a stack starts a quick craft drag
/// instead. `cloning` is `keyPickItem` with infinite materials.
@@ -301,4 +313,35 @@ mod tests {
Some(Input::Pickup)
);
}
/// #1737: a press whose release never arrives (the pointer left the
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
/// every later press, so the Shift quick move and the clicks on the
/// inventory go dead. The drag belongs to the screen that has it
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
/// that takes the pointer over drops it and the quick move works again.
#[test]
fn a_lost_release_does_not_wedge_the_quick_move() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release never arrives.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
// The stuck drag swallows the next press: the Shift quick move stops
// working and the inventory stops answering.
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
// The screen that takes the pointer over starts with its own state.
clicks.cancel_drag();
assert!(clicks.quick_crafting().is_none());
// The quick move works again.
assert_eq!(
clicks.press(&slot, Button::Right, true, true, false),
Some(Input::QuickMove)
);
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
+66 -1
View File
@@ -98,9 +98,34 @@ pub enum BlockEntity {
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
@@ -190,6 +215,9 @@ impl Skull {
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<crate::skins::NameOrId>,
pub fallback: &'static str,
}
impl SkullSkin {
@@ -198,10 +226,21 @@ impl SkullSkin {
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
Some((url, location))
});
// A `Dynamic` profile (a name or id only) resolves through the
// services when the skull is drawn.
let dynamic = crate::skins::dynamic_nbt(profile);
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
let fallback = crate::skins::profile_uuid_nbt(profile)
.or_else(|| {
crate::skins::nbt_text(profile.get("name"))
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
})
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self { download, fallback }
Self {
download,
dynamic,
fallback,
}
}
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
@@ -366,6 +405,9 @@ impl BlockEntity {
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:end_gateway" {
return Some(Self::EndGateway(EndGateway::default()));
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
@@ -381,6 +423,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -418,8 +463,10 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -436,6 +483,7 @@ impl BlockEntity {
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -443,6 +491,7 @@ impl BlockEntity {
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -456,9 +505,11 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -480,9 +531,11 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -728,6 +781,18 @@ impl Furnace {
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
+16 -3
View File
@@ -105,9 +105,13 @@ impl Report {
}
}
/// The release this binary is (`v0.2.3`); for a release from before 0.2, its tag and the client's
/// crate version; for a build from source, that version alone.
/// The release this binary is (`v0.2.3`); a nightly's label (`Nightly 2026-10-28 · 0c02067`); for a
/// release from before 0.2, its tag and the client's crate version; for a build from source, that
/// version alone.
pub fn version() -> String {
if let Some(label) = horizon_core::release::nightly_label() {
return label;
}
let version = env!("CARGO_PKG_VERSION");
match (horizon_core::release::RELEASE, release_tag()) {
(Some(_), Some(tag)) => tag,
@@ -117,8 +121,12 @@ pub fn version() -> String {
}
/// The release this binary came with: the version release.yml built in (docs/versioning.md), else
/// the folder the launcher installed it in, named after the tag (releases from before 0.2).
/// the folder the launcher installed it in, named after the tag (releases from before 0.2). A
/// nightly is its version as the launcher installs it (`nightly-2026-10-28-0c02067`).
pub fn release_tag() -> Option<String> {
if let Some(nightly) = horizon_core::release::nightly_version() {
return Some(nightly);
}
if let Some(version) = horizon_core::release::RELEASE {
return Some(format!("v{version}"));
}
@@ -525,6 +533,11 @@ mod tests {
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
if let Some(label) = horizon_core::release::nightly_label() {
assert_eq!(super::version(), label);
assert_eq!(release_tag(), horizon_core::release::nightly_version());
return;
}
match horizon_core::release::RELEASE {
Some(version) => {
assert_eq!(release_tag(), Some(format!("v{version}")));
+17 -13
View File
@@ -2,7 +2,7 @@
//! add/update with hidden effects, per-tick application of the periodic
//! effects (regeneration, poison, wither, hunger, saturation, instant
//! effects), and the derived flags the movement and damage code read.
use crate::brewing;
use horizon_client_core::potions;
#[derive(Clone, Debug, PartialEq)]
pub struct EffectInstance {
@@ -179,13 +179,14 @@ impl PlayerEffects {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, visible).
/// duration, amplifier, ambient, particles visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, _) in saved {
if let Some(def) = brewing::effect(id) {
for (id, duration, amplifier, ambient, particles) in saved {
if let Some(def) = potions::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
effects.add(e);
}
}
@@ -213,8 +214,9 @@ impl PlayerEffects {
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick). Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -224,6 +226,7 @@ impl PlayerEffects {
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
let mut actions = Vec::new();
@@ -246,6 +249,7 @@ impl PlayerEffects {
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
match answer {
@@ -302,10 +306,10 @@ impl PlayerEffects {
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let mut lasting = Vec::new();
let mut now = Vec::new();
if let Some(def) = potion.and_then(brewing::potion) {
if let Some(def) = potion.and_then(potions::potion) {
for (id, duration, amp) in def.effects {
let id = brewing::effect(id).map_or("speed", |e| e.id);
if brewing::effect(id).is_some_and(|e| e.instant) {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
// withScaledDuration: max(floor(d * scale), 1).
@@ -465,13 +469,13 @@ mod tests {
};
// The first tick adds Raid Omen and stops there (the iterator's
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
let (_, added) = fx.tick_with(&mut convert);
let (_, added) = fx.tick_with(&mut convert, false);
assert!(added);
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
assert_eq!(fx.get("speed").unwrap().duration, 50);
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
// The second refreshes Raid Omen and drops Bad Omen.
let (_, added) = fx.tick_with(&mut convert);
let (_, added) = fx.tick_with(&mut convert, false);
assert!(!added);
assert!(fx.get("bad_omen").is_none());
assert_eq!(fx.get("speed").unwrap().duration, 49);
@@ -486,9 +490,9 @@ mod tests {
seen.push(e.duration);
OmenTick::Remove
};
fx.tick_with(&mut watch);
fx.tick_with(&mut watch, false);
assert!(fx.get("raid_omen").is_some());
fx.tick_with(&mut watch);
fx.tick_with(&mut watch, false);
assert!(fx.get("raid_omen").is_none());
assert_eq!(seen, vec![1]);
}
+26
View File
@@ -545,6 +545,32 @@ impl Enchantments {
}
out
}
/// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the
/// `minecraft:attributes` effects of `stack`'s enchantments as rows of
/// (attribute, id, amount, operation, slot group), one per slot group the
/// enchantment lists.
pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> {
let mut rows = Vec::new();
for (id, level) in stack.enchantments() {
let Some(e) = self.get(id) else { continue };
for a in e.effects["minecraft:attributes"]
.as_array()
.into_iter()
.flatten()
{
for slot in &e.slots {
rows.push((
a["attribute"].as_str().unwrap_or("").to_owned(),
a["id"].as_str().unwrap_or("").to_owned(),
f64::from(level_value(&a["amount"], *level)),
a["operation"].as_str().unwrap_or("add_value").to_owned(),
slot.clone(),
));
}
}
}
rows
}
/// Sum of `add_value` amounts of one attribute from enchantments on `stack`.
pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 {
stack.map_or(0.0, |s| {
+24 -4
View File
@@ -582,17 +582,21 @@ impl Experience {
7 + level * 2
}
}
/// Returns true when a level was gained.
pub fn give(&mut self, points: i32) -> bool {
let start = self.level;
/// Returns the first multiple of 5 the player reached, where each level
/// gained would play the level-up sound (`Player.giveExperienceLevels`).
pub fn give(&mut self, points: i32) -> Option<i32> {
let mut sound_level = None;
self.progress += points as f32 / Self::needed(self.level) as f32;
self.total = self.total.saturating_add(points).max(0);
while self.progress >= 1.0 {
self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32;
self.level += 1;
if self.level % 5 == 0 {
sound_level.get_or_insert(self.level);
}
self.progress /= Self::needed(self.level) as f32;
}
self.level > start
sound_level
}
}
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
@@ -767,9 +771,24 @@ pub struct ArmorStand {
pub name_visible: bool,
/// Game time of the last hit (the wobble and the second-hit window).
pub last_hit: i64,
/// `DATA_HEAD_POSE` to `DATA_RIGHT_LEG_POSE`: head, body, left arm, right
/// arm, left leg, right leg, in degrees (`POSE_KEYS` order).
pub pose: [[f32; 3]; 6],
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// The `Pose` compound's keys.
pub const POSE_KEYS: [&'static str; 6] =
["Head", "Body", "LeftArm", "RightArm", "LeftLeg", "RightLeg"];
/// `ArmorStand.DEFAULT_*_POSE`.
pub const DEFAULT_POSE: [[f32; 3]; 6] = [
[0.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
[-10.0, 0.0, -10.0],
[-15.0, 0.0, 10.0],
[-1.0, 0.0, -1.0],
[1.0, 0.0, 1.0],
];
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
fire_ticks: 0,
@@ -788,6 +807,7 @@ impl ArmorStand {
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
}
}
pub fn environment_tick(
+87 -16
View File
@@ -14,7 +14,8 @@ struct Globals {
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
// y: Nether cardinal lighting, z: how many dynamic_lights are set,
// w: the glint scroll clock (`Glint::scroll_ticks`).
time: vec4<f32>,
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
@@ -27,6 +28,12 @@ struct Globals {
@group(0) @binding(2) var atlas_sampler: sampler;
@group(0) @binding(3) var lightmap: texture_2d<f32>;
@group(0) @binding(4) var lightmap_sampler: sampler;
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
@group(0) @binding(5) var glint_tex: texture_2d<f32>;
@group(0) @binding(6) var glint_sampler: sampler;
// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with
// the same repeating, linearly filtered sampler as the item glint.
@group(0) @binding(7) var glint_armor_tex: texture_2d<f32>;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -44,6 +51,9 @@ struct Out {
// entity.vsh's lightMapColor, multiplied in after the overlay.
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
@location(11) glint_uv: vec2<f32>,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -103,18 +113,23 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
out.overlay = overlay;
out.glint_uv = vec2(0.0);
if overlay == 4u { out.spherical = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position, uv, overlay, color, light, normal);
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay, color, light, normal);
out.glint_uv = glint_uv;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
// subtraction as the former CPU path before adding each local f32 position.
// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV.
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(6) offset: vec3<f32>) -> Out {
@location(7) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
}
struct CloudInfo {
@@ -166,6 +181,27 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d >= end { return 1.0; }
return (d - start) / (end - start);
}
// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING.
// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the
// common period of both offsets.
fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
let ticks = globals.time.w;
let offset0 = (ticks % 110000.0) / 110000.0;
let offset1 = (ticks % 30000.0) / 30000.0;
let angle = 0.17453292;
let c = cos(angle);
let s = sin(angle);
let scaled = model * 0.16;
return vec2(scaled.x * c - scaled.y * s - offset0,
scaled.x * s + scaled.y * c + offset1);
}
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
// is squared along with the sample.
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
let alpha = globals.fog_ends.w;
let g = alpha * glint;
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
}
// CRUMBLING_UV_BEGIN
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
let size = vec2<f32>(dimensions);
@@ -203,25 +239,60 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
color *= in.light_color;
if in.overlay == 7u || in.overlay == 8u {
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
// enchanted_glint sheen drifting diagonally over the item's shape.
let px = in.uv * vec2<f32>(textureDimensions(atlas));
let t = globals.misc.y * 0.05;
let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5;
let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5;
let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0);
let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer);
color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a);
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
// 33: a translucent item with the glint.
// Enchantment glint: item.fsh's GLINT path, sampled from
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
} else if in.overlay == 13u {
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
color = glint_over(
color,
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
);
}
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
return color;
return celestial_color(in);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
// as breaking::stage_light packs them.
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let bytes = round(in.color * 255.0);
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
if color.a < 0.1 { discard; }
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -186,6 +186,8 @@ mod tests {
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
};
let prior = vertex([0.0; 3]);
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
+359 -25
View File
@@ -187,6 +187,31 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
// LivingEntity.addAdditionalSaveData: `active_effects` only when there are
// any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one
// given with `hideParticles` shows neither particles nor its icon).
if m.extra.effects.is_empty() {
map.remove(&horizon_world::nbt::JavaString::from("active_effects"));
} else {
let effects = m
.extra
.effects
.iter()
.map(|&(effect, duration, amplifier)| {
let shown = !m.extra.hidden_effects.contains(&effect);
let mut e = vec![
("id", string(crate::brewing::namespaced(effect))),
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
("duration", Tag::Int(duration)),
];
if !shown {
e.push(("show_particles", Tag::Byte(0)));
}
e.push(("show_icon", Tag::Byte(i8::from(shown))));
Tag::compound(e)
});
map.insert("active_effects".into(), Tag::List(effects.collect()));
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
@@ -244,7 +269,51 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
}
let species = m.species();
if let Some((_, variant)) =
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
{
map.insert("variant".into(), string(&variant));
}
if species == crate::mobs::Species::Chicken {
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
}
let sounds = crate::mobs::finalize::sound_variants(species);
if !sounds.is_empty() {
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
let sound = sound.copied().unwrap_or("classic");
map.insert(
"sound_variant".into(),
string(&format!("minecraft:{sound}")),
);
}
match id {
// Fox.addAdditionalSaveData; Horizon models only the type.
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
for (key, value) in [
("Trusted", Tag::List(Vec::new())),
("Sleeping", Tag::Byte(0)),
("Sitting", Tag::Byte(0)),
("Crouching", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
}
}
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
}
"minecraft:goat" => {
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
map.entry(key.into()).or_insert(value);
@@ -260,10 +329,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:wolf" => {
if let Some(owner) = m.extra.owner {
@@ -273,10 +338,18 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:panda" => {
use crate::mobs::panda::GENES;
for (key, gene) in [
("MainGene", m.extra.variant),
("HiddenGene", m.extra.variant2),
] {
map.insert(
key.into(),
string(GENES[usize::from(gene).min(GENES.len() - 1)]),
);
}
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
@@ -311,6 +384,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
for key in ["EatingHaystack", "Bred"] {
map.entry(key.into()).or_insert(Tag::Byte(0));
}
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
}
@@ -426,7 +503,32 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:axolotl" => write_axolotl_memories(m, map),
"minecraft:axolotl" => {
write_axolotl_memories(m, map);
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:parrot" => {
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:salmon" => {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
map.insert("type".into(), string(size));
}
// TropicalFish.addAdditionalSaveData: the packed variant.
"minecraft:tropical_fish" => {
let pattern = m.extra.variant.min(11);
let packed = i32::from(pattern >= 6)
| i32::from(pattern % 6) << 8
| i32::from(m.extra.variant2 & 15) << 16
| i32::from(m.extra.variant3 & 15) << 24;
map.insert("Variant".into(), Tag::Int(packed));
}
"minecraft:allay" => {
// InventoryCarrier: the fetched stack; the liked player memory.
let mut inv = Vec::new();
@@ -487,6 +589,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new()));
}
}
// Armadillo.addAdditionalSaveData: the time left to the next scute.
"minecraft:armadillo" => {
map.insert("scute_time".into(), Tag::Int(m.extra.egg_time));
}
"minecraft:ravager" => {
map.insert("AttackTick".into(), Tag::Int(m.extra.counter));
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
@@ -800,6 +906,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
// with the type's and saved before it drops to the one it rolled. (Only
// the base is read here, so the clamp would cut a leader zombie's bonus.)
if crate::mobs::equine::is_equine(m.species()) {
m.health = m.health.min(m.attrs.max_health);
}
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
@@ -854,6 +966,25 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.no_gravity = flag(tag, "NoGravity");
m.glowing = flag(tag, "Glowing");
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
// LivingEntity.readAdditionalSaveData: the saved `active_effects`, the last
// entry of an effect winning.
if let Some(Tag::List(effects)) = tag.get("active_effects") {
for e in effects {
let Some(def) = text(e.get("id")).and_then(|id| crate::brewing::effect(&id)) else {
continue;
};
let effect = crate::brewing::namespaced(def.id);
let duration = number(e.get("duration")).unwrap_or(0) as i32;
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
m.extra.effects.retain(|x| x.0 != effect);
m.extra.effects.push((effect, duration, amplifier));
// Details.CODEC: `show_particles` defaults to true.
m.extra.hidden_effects.retain(|h| *h != effect);
if number(e.get("show_particles")) == Some(0) {
m.extra.hidden_effects.push(effect);
}
}
}
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
if m.species() == crate::mobs::Species::Bat {
@@ -935,20 +1066,50 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.vector[0] = 0.7;
}
}
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
&& let Some(v) = text(tag.get("variant"))
{
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
}
if m.species() == crate::mobs::Species::Chicken
&& let Some(t) = number(tag.get("EggLayTime"))
{
m.extra.egg_time = t as i32;
}
if let Some(v) = text(tag.get("sound_variant")) {
let v = v.trim_start_matches("minecraft:");
let sounds = crate::mobs::finalize::sound_variants(m.species());
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
}
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
v @ (0..=5 | 99) => v as u8,
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
_ => {}
}
if m.species() == crate::mobs::Species::Cat {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
.iter()
.position(|n| *n == v)
.unwrap_or(0) as u8;
}
}
if m.species() == crate::mobs::Species::Panda {
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
m.extra.variant = gene("MainGene");
m.extra.variant2 = gene("HiddenGene");
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
@@ -959,14 +1120,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
.iter()
.position(|(n, _)| *n == v)
.unwrap_or(0) as u8;
}
}
if matches!(
m.species(),
@@ -985,6 +1138,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if m.species() == crate::mobs::Species::Axolotl {
restore_axolotl_memories(&mut m, tag);
}
read_variant(&mut m, tag);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
if m.species() == crate::mobs::Species::Allay {
@@ -1065,6 +1219,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
st.vibration = crate::sculk::Data::from_nbt(listener);
}
}
// Armadillo.readAdditionalSaveData: keeps the constructor's time when absent.
if m.species() == crate::mobs::Species::Armadillo
&& let Some(t) = number(tag.get("scute_time"))
{
m.extra.egg_time = t as i32;
}
if m.species() == crate::mobs::Species::Ravager {
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
@@ -1689,6 +1849,15 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
}
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
// ArmorStand.addAdditionalSaveData: the `Pose` parts that are not default.
let pose: Vec<(&str, Tag)> = ArmorStand::POSE_KEYS
.into_iter()
.zip(a.pose)
.zip(ArmorStand::DEFAULT_POSE)
.filter(|((_, rotation), default)| rotation != default)
.map(|((key, rotation), _)| (key, floats(&rotation)))
.collect();
put(&mut tag, "Pose", Tag::compound(pose));
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
if let Tag::Compound(map) = &mut tag {
for (key, value) in [
@@ -1728,6 +1897,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
a.invisible = flag(tag, "Invisible");
a.marker = flag(tag, "Marker");
a.on_ground = flag(tag, "OnGround");
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
// three numbers, the others staying at their defaults.
if let Some(pose) = tag.get("Pose") {
for (rotation, key) in a.pose.iter_mut().zip(ArmorStand::POSE_KEYS) {
if let Some(Tag::List(values)) = pose.get(key)
&& values.len() == 3
{
for (r, v) in rotation.iter_mut().zip(values) {
*r = float(Some(v)).unwrap_or(0.0) as f32;
}
}
}
}
Some(a)
}
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
@@ -2166,6 +2348,41 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
}
}
}
/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and
/// `TropicalFish` read; a missing tag is the default variant (a medium
/// salmon, otherwise index 0).
fn read_variant(m: &mut Mob, tag: &Tag) {
use crate::mobs::Species as S;
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
match m.species() {
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
S::Rabbit => {
m.extra.variant = match int("RabbitType") {
v @ (0..=5 | 99) => v as u8,
_ => 0,
}
}
S::Salmon => {
m.extra.variant = text(tag.get("type"))
.and_then(|t| SALMON_SIZES.iter().position(|v| *v == t))
.unwrap_or(1) as u8;
}
S::TropicalFish => {
let packed = int("Variant");
let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF);
// TropicalFish.Pattern.byId: an unknown id is a kob.
m.extra.variant = if size <= 1 && index < 6 {
(size * 6 + index) as u8
} else {
0
};
m.extra.variant2 = ((packed >> 16) & 15) as u8;
m.extra.variant3 = ((packed >> 24) & 15) as u8;
}
_ => {}
}
}
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
let ttl = memories
@@ -2567,6 +2784,123 @@ mod sheep_nbt_tests {
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
#[test]
fn farm_animals_keep_their_variant_and_sound_variant() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
types["minecraft:pig"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(
text(tag.get("variant")).as_deref(),
Some("minecraft:temperate")
);
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:classic")
);
// Spawned in a warm biome with the mini sounds.
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:mini")
);
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
let mut hen = Mob::new(
3,
types["minecraft:chicken"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
let back = mob_from_nbt(&tag, &types, 4).unwrap();
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
// The sound variants are indices into the registries finalizeSpawn draws from.
use crate::mobs::{Species, finalize};
let registries = finalize::SpawnRegistries::load(&root);
for (species, registry) in [
(Species::Pig, &registries.pig_sound),
(Species::Cow, &registries.cow_sound),
(Species::Chicken, &registries.chicken_sound),
(Species::Cat, &registries.cat_sound),
(Species::Wolf, &registries.wolf_sound),
] {
assert_eq!(
finalize::sound_variants(species),
registry.names,
"{species:?}"
);
}
}
#[test]
fn foxes_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
let round_trip = |m: &Mob| {
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
};
let mut fox = new("minecraft:fox");
fox.extra.variant = 1;
let (back, tag) = round_trip(&fox);
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
assert_eq!(number(tag.get("Sleeping")), Some(0));
assert_eq!(back.extra.variant, 1);
let mut rabbit = new("minecraft:rabbit");
rabbit.extra.variant = 4;
let (back, tag) = round_trip(&rabbit);
assert_eq!(number(tag.get("RabbitType")), Some(4));
assert_eq!(back.extra.variant, 4);
let mut goat = new("minecraft:goat");
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
let (back, tag) = round_trip(&goat);
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
assert_eq!(
(back.extra.screaming, back.extra.lost_horns),
(true, [false, true])
);
// A goat saved without horn flags has both, as vanilla reads it.
let bare = Tag::compound([
("id", string("minecraft:goat")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
assert_eq!(
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
[false; 2]
);
}
}
#[cfg(test)]
+2
View File
@@ -48,6 +48,8 @@ pub fn flames(
color: 0xffff_ffff,
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
remaining -= 0.45;
+8 -3
View File
@@ -889,12 +889,17 @@ impl Font {
if s.contains('§') {
return self.width_text(&Text::legacy(s));
}
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
crate::bidi::visual_str(s)
.chars()
.map(|c| self.glyph(c).advance)
.sum::<f32>()
.ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs.
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
pub fn width_text(&self, t: &Text) -> f32 {
t.resolved()
let t = t.resolved();
crate::bidi::visual(&t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
File diff suppressed because it is too large. Load diff
+304 -35
View File
@@ -9,6 +9,7 @@ use horizon_client::{
text::{self, Style, Text},
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -21,6 +22,9 @@ pub(super) struct Toast {
frame: Frame,
title: Text,
slide: Slide,
/// `ToastInstance.firstSlotIndex`: the 32-pixel slot it stacks in once it
/// leaves the queue.
slot: Option<usize>,
/// A `RecipeToast`: the crafting station and result of each display
/// unlocked while it lasts (`recipeItems`); empty for an advancement.
recipes: Vec<(String, ItemStack)>,
@@ -36,6 +40,7 @@ impl Toast {
frame: Frame::Task,
title: Text::literal(""),
slide: Slide::default(),
slot: None,
recipes: items,
changed: true,
last_changed: 0,
@@ -82,6 +87,8 @@ pub(super) struct AdvState {
pub(super) quiet: bool,
/// The inventory last seen by `inventory_changed`.
pub(super) last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
pub(super) levitation: Option<([f64; 3], u64)>,
/// While the game acts for a network player (`as_guest`): the
/// advancements whose progress changed (`progressChanged`), the recipes
/// unlocked and the announcements, for their client and everyone's.
@@ -180,6 +187,14 @@ impl adv::World for View<'_, '_> {
"block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)),
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
"damage_type" => self
.0
.res
.stations()
.enchantments
.damage_tags
.get(tag)
.is_some_and(|t| t.contains(id)),
_ => false,
}
}
@@ -524,6 +539,7 @@ impl Game<'_> {
frame: d.frame,
title: text_of(&d.title),
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
changed: false,
last_changed: 0,
@@ -647,6 +663,28 @@ impl Game<'_> {
};
adv::conditions(&c["player"], &ctx, w)
});
// ServerPlayer.onEffectAdded and onEffectsRemoved keep the start of a
// levitation; tick fires LEVITATION while there is one.
let levitating = self
.potions
.effects
.list
.iter()
.any(|e| e.id == "levitation");
match (levitating, self.adv.levitation) {
(true, None) => {
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.adv.levitation = at.map(|at| (at, self.ticks));
}
(false, Some(_)) => self.adv.levitation = None,
_ => {}
}
if let Some((start, since)) = self.adv.levitation {
self.trigger_levitation(start, (self.ticks - since) as i64);
}
if self.ticks.is_multiple_of(20) {
let me = self.player_entity();
self.trigger("minecraft:location", |c, w| {
@@ -681,6 +719,123 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `LevitationTrigger`: how far the player rose from where it started
/// levitating, and for how long.
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
let me = self.player_entity();
self.trigger("minecraft:levitation", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
&& adv::range_i(&c["duration"], duration)
});
}
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
/// what the player looks at (`PlayerPredicate.lookingAt`).
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
if self.remote.is_some() || self.no_player_here() {
return;
}
let mut me = self.player_entity();
me.looking_at = self.looking_at_entity().map(Box::new);
let it = item_view(stack);
self.trigger("minecraft:using_item", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
});
}
/// The mob a 100 block ray from the eyes meets first, if no block is in
/// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`).
fn looking_at_entity(&self) -> Option<EntityView> {
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let (m, _) = self
.mobs
.iter()
.filter(|m| m.alive())
.filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?)))
.min_by(|a, b| a.1.total_cmp(&b.1))?;
// `LivingEntity.hasLineOfSight`: eye to the target's eye height,
// `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks.
let from = Vec3::new(eye[0], eye[1], eye[2]);
let to = Vec3::new(m.position.x, m.eye_y(), m.position.z);
let world = self.mob_world_base(&[]);
let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false);
clear.then(|| self.mob_entity(m))
}
/// `PlayerHurtEntityTrigger`: the player, the damage and the victim.
pub(super) fn trigger_player_hurt_entity(
&mut self,
victim: &EntityView,
damage: &adv::DamageView,
) {
let me = self.player_entity();
let (victim, damage) = (victim.clone(), damage.clone());
self.trigger("minecraft:player_hurt_entity", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&victim),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::damage(&c["damage"], &damage, w, me.position)
&& adv::conditions(&c["entity"], &vctx, w)
});
}
/// The player's hit on `victim` as `LivingEntity.hurtServer` reports it:
/// `kind` damage of `amount` from `direct` (the player's own hand when
/// `None`), nothing blocked.
pub(super) fn player_hit(
&mut self,
victim: &EntityView,
kind: &str,
direct: Option<EntityView>,
amount: f32,
) {
let me = self.player_entity();
self.trigger_player_hurt_entity(
victim,
&adv::DamageView {
kind: kind.to_owned(),
is_direct: direct.is_none(),
direct: direct.or_else(|| Some(me.clone())),
source: Some(me),
original: amount,
actual: amount,
blocked: false,
},
);
}
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
let me = self.player_entity();
let damage = damage.clone();
self.trigger("minecraft:entity_hurt_player", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::damage(&c["damage"], &damage, w, me.position)
});
}
/// A player condition plus one item (`item` key).
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
let me = self.player_entity();
@@ -708,6 +863,72 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
/// brewing stand.
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
let me = self.player_entity();
let potion = stack.potion().map(str::to_owned);
self.trigger("minecraft:brewed_potion", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::potions(&c["potion"], potion.as_deref(), w)
});
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
let villager = villager.clone();
let it = item_view(stack);
self.trigger("minecraft:villager_trade", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&villager),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["villager"], &vctx, w)
&& adv::item(&c["item"], &it, w)
});
}
/// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook)
/// and the items it caught.
pub(super) fn trigger_fishing_rod_hooked(
&mut self,
rod: &ItemStack,
hooked: &EntityView,
items: &[ItemStack],
) {
let me = self.player_entity();
let rod = item_view(rod);
let hooked = hooked.clone();
let items: Vec<ItemView> = items.iter().map(item_view).collect();
self.trigger("minecraft:fishing_rod_hooked", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = adv::Entities {
this: Some(&hooked),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::item(&c["rod"], &rod, w)
&& adv::conditions(&c["entity"], &ectx, w)
&& (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w)))
});
}
/// `EnterBlockTrigger`: the block, its state properties and the player.
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
@@ -806,58 +1027,105 @@ impl Game<'_> {
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
});
}
/// `ToastManager` with the `AdvancementToast` and the `RecipeToast`,
/// one at a time.
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch) {
/// `ToastManager.SLOT_COUNT`.
pub(super) const TOAST_SLOTS: usize = 5;
/// `ToastManager.update` and `extractRenderState` for the `AdvancementToast`s and
/// `RecipeToast`s: up to five stack in 32-pixel slots, the rest queue. `hidden` (F1)
/// keeps them timing out without drawing them.
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch, hidden: bool) {
let multiplier = f64::from(self.settings.notification_display_time);
let Some(t) = self.adv.toasts.front_mut() else {
return;
};
let first = t.slide.origin.is_none();
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
let now = origin.elapsed().as_millis() as i64;
let mut slide = std::mem::take(&mut t.slide);
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
t.slide = slide;
if finished {
self.adv.toasts.pop_front();
let mut sound_played = false;
let mut i = 0;
while i < self.adv.toasts.len() {
let t = &mut self.adv.toasts[i];
if t.slot.is_none() {
i += 1;
continue;
}
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
let now = origin.elapsed().as_millis() as i64;
let mut slide = std::mem::take(&mut t.slide);
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
t.slide = slide;
if finished {
self.adv.toasts.remove(i);
continue;
}
// Only the first visibility change of an update plays its sound.
if let Some(show) = sound
&& !std::mem::replace(&mut sound_played, true)
{
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
self.audio
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
}
i += 1;
}
// The queued toasts take the free slots in order; a challenge's own sound
// plays once as it leaves the queue.
let mut used = self.toast_slots_used();
let mut challenge_sound = false;
for t in &mut self.adv.toasts {
if t.slot.is_some() {
continue;
}
let Some(slot) = (0..Self::TOAST_SLOTS).find(|s| used & (1 << s) == 0) else {
break;
};
used |= 1 << slot;
t.slot = Some(slot);
challenge_sound |= !t.is_recipe() && t.frame == Frame::Challenge;
}
if challenge_sound {
self.audio
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
}
if hidden {
return;
}
for i in 0..self.adv.toasts.len() {
if let Some(slot) = self.adv.toasts[i].slot {
self.draw_toast(batch, i, 32.0 * slot as f32, multiplier);
}
}
}
/// The slots held by shown advancement, recipe and pack toasts, as bits.
pub(super) fn toast_slots_used(&self) -> u32 {
let packs = self.server_packs.toast_slots();
self.adv
.toasts
.iter()
.filter_map(|t| t.slot)
.chain(packs)
.fold(0, |used, slot| used | 1 << slot)
}
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
let t = &self.adv.toasts[index];
let x = batch.width - 160.0 * t.slide.portion;
let fully_for = t.slide.fully_for;
let (title, frame, icon) = (t.title.clone(), t.frame, t.icon.clone());
let recipe = t
.is_recipe()
.then(|| t.recipes[t.displayed_recipe(fully_for, multiplier)].clone());
// ToastManager.update: the sound of the new visibility, and a
// challenge's own sound as it leaves the queue.
if let Some(show) = sound {
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
self.audio
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
}
if first && recipe.is_none() && frame == Frame::Challenge {
self.audio
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
}
if let Some((station, item)) = recipe {
// RecipeToast: black description under a purple title, the
// station at 0.6 scale in the corner over the cycling result.
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
batch.sprite(atlas, "gui/sprites/toast/recipe", x, y);
let title = self.translate("recipe.toast.title", "New Recipe(s) Unlocked!");
let description = self.translate("recipe.toast.description", "Check your recipe book");
let atlas = &self.gui.atlas;
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
batch.text(atlas, &title, x + 30.0, y + 7.0, 0xFF50_0050, false);
batch.text(atlas, &description, x + 30.0, y + 18.0, 0xFF00_0000, false);
let start = batch.vertices.len();
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, 3.0);
batch.scale_since(start, x, 0.0, 0.6, 0.6);
self.draw_item_model(batch, &item, x + 8.0, 8.0);
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, y + 3.0);
batch.scale_since(start, x, y, 0.6, 0.6);
self.draw_item_model(batch, &item, x + 8.0, y + 8.0);
return;
}
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
let (key, color) = match frame {
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
Frame::Goal => ("advancements.toast.goal", 0xFF00_FFFF),
@@ -867,12 +1135,12 @@ impl Game<'_> {
atlas,
&Text::translate(key, &[]),
x + 30.0,
7.0,
y + 7.0,
color,
false,
);
batch.text_styled(atlas, &title, x + 30.0, 18.0, u32::MAX, false);
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, 8.0);
batch.text_styled(atlas, &title, x + 30.0, y + 18.0, u32::MAX, false);
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
}
}
/// `Advancement.name`: the bracketed title in the frame's colour, with the
@@ -1053,6 +1321,7 @@ impl Game<'_> {
frame: d.frame,
title: text_of(&d.title),
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
changed: false,
last_changed: 0,
+22
View File
@@ -419,6 +419,9 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -565,6 +568,22 @@ impl Game<'_> {
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
Mode::CreateFlat {
name,
seed,
options,
flat,
..
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
"height": layer.height,
})).collect::<Vec<_>>(),
}),
Mode::Edit {
world,
name,
@@ -707,9 +726,12 @@ impl Game<'_> {
// the recipe buttons of the page, page arrows, filter and tabs.
let book = self.recipe_book_targets();
if !book.is_empty() {
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
v["recipe_book"] = json!({
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
"known": self.recipe_book_known(),
"open": open,
"filtering": filtering,
});
}
v["slot_ids"] = json!(
+3 -2
View File
@@ -131,7 +131,8 @@ impl Game<'_> {
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
const PITCHES: [f32; 16] = [
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
@@ -160,7 +161,7 @@ impl Game<'_> {
}
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
let id = self.mobs[index].id;
self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY);
self.mob_event_particles(id, 18);
self.audio
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
self.consume_held();
+49 -18
View File
@@ -627,21 +627,8 @@ impl Game<'_> {
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let mut poses = Poses::new(model);
// ArmorStand.DEFAULT_*_POSE and ArmorStandModel part visibility.
let deg = |v: f32| v.to_radians();
for (name, rot) in [
("left_arm", [-10.0, 0.0, -10.0]),
("right_arm", [-15.0, 0.0, 10.0]),
("left_leg", [-1.0, 0.0, -1.0]),
("right_leg", [1.0, 0.0, 1.0]),
] {
if poses.has(name) {
let pose = poses.get(name);
pose[3] = deg(rot[0]);
pose[4] = deg(rot[1]);
pose[5] = deg(rot[2]);
}
}
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
apply_pose(&mut poses, &a.pose);
// Item layers pose from the hidden arms too (`visible` only skips drawing).
let posed = poses.clone();
let mut hide = |name: &str| {
@@ -711,7 +698,23 @@ impl Game<'_> {
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
let layer_poses = Poses::new(part);
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
.res
.items()
.enchantments
.props
.get(&stack.item)
.is_some_and(|p| p.glint);
let overlay = if stack.has_foil(item_glint) {
horizon_client::mobs::render::ARMOR_GLINT
} else {
0
};
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
let mut layer_poses = Poses::new(part);
apply_pose(&mut layer_poses, &a.pose);
horizon_client::mobs::render::draw(
part,
base,
@@ -719,7 +722,7 @@ impl Game<'_> {
uv,
color,
packed,
0,
overlay,
out,
);
// EquipmentLayerRenderer: the trim layer over the armour.
@@ -734,7 +737,7 @@ impl Game<'_> {
trim,
0xFFFF_FFFF,
packed,
0,
overlay,
out,
);
}
@@ -743,6 +746,33 @@ impl Game<'_> {
}
}
}
/// The `Pose` rotations on the parts of an armor stand model or its armor
/// (`ArmorStandArmorModel.setupAnim`); the body also turns the sticks.
fn apply_pose(poses: &mut Poses, pose: &[[f32; 3]; 6]) {
let parts: [&[&str]; 6] = [
&["head"],
&[
"body",
"right_body_stick",
"left_body_stick",
"shoulder_stick",
],
&["left_arm"],
&["right_arm"],
&["left_leg"],
&["right_leg"],
];
for (names, rotation) in parts.into_iter().zip(pose) {
for name in names {
if poses.has(name) {
let part = poses.get(name);
part[3] = rotation[0].to_radians();
part[4] = rotation[1].to_radians();
part[5] = rotation[2].to_radians();
}
}
}
}
fn overlaps(a: Aabb, b: Aabb) -> bool {
let (a0, a1, b0, b1) = (
a.min_position(),
@@ -877,6 +907,7 @@ impl Game<'_> {
lights: Some(horizon_client::block_entity_render::diffuse_lights(
self.dim == super::Dim::Nether,
)),
glint: self.glint(),
};
let profile = stack
.extra
+138 -5
View File
@@ -3,8 +3,10 @@
//! player breaks the hive or fire reaches it.
use super::Game;
use horizon_client::{
Result,
beehive::{self, Beehive, HiveHost, Release},
containers::BlockEntity,
items::ItemStack,
mobs::{Mob, Who, bee},
};
use horizon_core::{
@@ -194,19 +196,139 @@ impl Game<'_> {
}
self.anger_nearby_bees(at);
}
/// `BeehiveBlock.useItemOn` with `held` in the acting hand: shears take
/// the honeycombs and a glass bottle a honey bottle from a full hive
/// (honey level 5), then the honey level resets and, unless a campfire
/// smokes the hive, the bees come out angry. True when it took the click.
pub(super) fn use_beehive(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> Result<bool> {
let Some(held) = held else { return Ok(false) };
let shears = &*held.item == "minecraft:shears";
if !shears && &*held.item != "minecraft:glass_bottle" {
return Ok(false);
}
let state = self.world.block(pos[0], pos[1], pos[2]);
if !matches!(
&*self.res.facts().get(state).name,
"minecraft:beehive" | "minecraft:bee_nest"
) {
return Ok(false);
}
let prop = |key: &str| {
self.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get(key).cloned())
};
if prop("honey_level").and_then(|v| v.parse::<i32>().ok()) != Some(5) {
return Ok(false);
}
let facing = beehive::facing_step(prop("facing").as_deref());
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if shears {
// dropHoneycomb (harvest/beehive), the shear sound, hurtAndBreak.
for stack in self.sequence_loot(
"minecraft:harvest/beehive",
"minecraft:harvest/beehive",
Some(held),
) {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:block.beehive.shear", at, 1.0, 1.0);
self.damage_held(1);
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
} else {
// shrink(1), then the honey bottle in the hand, the inventory or
// on the ground.
self.audio.play("minecraft:item.bottle.fill", at, 1.0, 1.0);
let cell = self.held_mut();
if let Some(s) = cell.as_mut() {
s.count -= 1;
if s.count == 0 {
*cell = None;
}
}
let bottle = ItemStack::new("minecraft:honey_bottle", 1);
if self.held().is_none() {
*self.held_mut() = Some(bottle);
} else if let Some(rest) = self.give(bottle)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
}
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
}
self.stats.add("used", &held.item, 1);
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
let key = (pos[0], pos[1], pos[2]);
if !smokey
&& matches!(self.world.block_entities.get(&key), Some(BlockEntity::Beehive(h)) if !h.bees.is_empty())
{
self.anger_nearby_bees(pos);
}
// resetHoneyLevel.
if let Some(reset) = self.with_props(state, &[("honey_level", "0")]) {
self.level_set(pos, reset)?;
}
if !smokey
&& let Some(BlockEntity::Beehive(mut hive)) = self.world.block_entities.take(&key)
{
// emptyAllLivingFromHive: the bees within 4 blocks of the player
// go for them.
let released = hive.release_all(
pos,
facing,
None,
Release::Emergency,
&mut GameHive { game: self },
);
self.world
.block_entities
.put(key, BlockEntity::Beehive(hive));
if let Some(p) = self.player_view().map(|p| p.position) {
for m in self.mobs.iter_mut().filter(|m| released.contains(&m.id)) {
if m.distance_sqr(p) <= 16.0 {
m.ai.target = Some(Who::Player);
}
}
}
}
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, held);
Ok(true)
}
/// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down)
/// without a target go for a player in the same box.
fn anger_nearby_bees(&mut self, at: [i32; 3]) {
/// without a target go for a random player in the same box, the window's
/// own or a guest (`None` is the window's player).
pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) {
let min = Vec3::new(
f64::from(at[0]) - 8.0,
f64::from(at[1]) - 6.0,
f64::from(at[2]) - 8.0,
);
let area = Aabb::new(min, Vec3::new(min.x + 17.0, min.y + 13.0, min.z + 17.0));
if !self
let mut players: Vec<Option<u32>> = Vec::new();
if self
.player_view()
.is_some_and(|p| p.bounds.intersects(area))
{
players.push(None);
}
if let Some(host) = self.lan.as_ref() {
players.extend(
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.filter(|g| super::lan_mobs::guest_bounds(g).intersects(area))
.map(|g| Some(g.conn)),
);
}
if players.is_empty() {
return;
}
let mut bees: Vec<usize> = (0..self.mobs.len())
@@ -219,9 +341,20 @@ impl Game<'_> {
.collect();
bees.retain(|&i| self.mobs[i].ai.target.is_none());
for i in bees {
// Util.getRandom over the one player.
let _ = self.world.random.next_int_bound(1);
// Util.getRandom over the players.
let pick = self
.world
.random
.next_int_bound(players.len() as i32)
.unwrap_or(0) as usize;
self.mobs[i].ai.target = Some(Who::Player);
// The player a mob sees is its guest binding, else the window's.
if let Some(host) = self.lan.as_mut() {
match players[pick] {
Some(conn) => host.mob_players.insert(self.mobs[i].id, conn),
None => host.mob_players.remove(&self.mobs[i].id),
};
}
}
}
}
@@ -76,23 +76,24 @@ impl Game<'_> {
/// A player opened (`open`) or closed the container at `key`: the opener
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
/// on both halves of a double chest (`CompoundContainer`).
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
/// on both halves of a double chest (`CompoundContainer`). True when the
/// count went from 0 to 1 or back to 0: the only moments the container
/// plays its open or close sound.
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) -> bool {
// Spectators never count as openers.
if !self.spectator() {
self.container_openers_changed(key, open);
}
!self.spectator() && self.container_openers_changed(key, open)
}
/// `container_opener_event` for a player who is not a spectator (a guest).
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let pos = [key.0, key.1, key.2];
let state = self.world.block(pos[0], pos[1], pos[2]).0;
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
self.lid_openers_changed(key, open);
let edge = self.lid_openers_changed(key, open);
if let Some(other) = self.chest_other_half(key) {
self.lid_openers_changed(other, open);
}
edge
}
_ if self
.res
@@ -102,26 +103,27 @@ impl Game<'_> {
.as_ref()
== "minecraft:barrel" =>
{
self.barrel_openers_changed(key, open);
self.barrel_openers_changed(key, open)
}
Some(Ticker::Shulker { .. }) => {
let shulker = self.be_anims.shulkers.entry(pos).or_default();
let (count, _) = if open {
let (count, edge) = if open {
shulker.start_open()
} else {
shulker.stop_open()
};
self.block_entity_event(pos, 1, count);
self.lan_block_event(pos, 1, count);
edge
}
_ => {}
_ => false,
}
}
/// `ContainerOpenersCounter.incrementOpeners` / `decrementOpeners` of the
/// chest at `key` (the host and the guests are counted), then
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
/// and for the guests.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
/// and for the guests. True when the count crossed zero.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let (before, count) = self.count_opener(key, open);
let pos = [key.0, key.1, key.2];
self.block_entity_event(pos, 1, count);
@@ -139,12 +141,15 @@ impl Game<'_> {
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
});
}
(before == 0) != (count == 0)
}
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
/// True when the count crossed zero.
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let (before, count) = self.count_opener(key, open);
if (before == 0) != (count == 0) {
let edge = (before == 0) != (count == 0);
if edge {
self.set_bool_prop(
key,
"open",
@@ -152,6 +157,7 @@ impl Game<'_> {
horizon_world::level::flags::UPDATE_ALL,
);
}
edge
}
/// One opener more (`open`) or less at `key`: the count before and after
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
@@ -547,7 +553,7 @@ impl Game<'_> {
});
}
/// `Entity.isInRain`: rain at the block position or at the top of the box.
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
pub(super) fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
let p = BlockPos::new(
at.x.floor() as i32,
at.y.floor() as i32,
@@ -725,21 +731,38 @@ impl Game<'_> {
/// the player head at `pos`. The skin download is started here; while it
/// is in flight (or when it cannot be got) the profile's default skin
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<String> {
let Some(BlockEntity::Skull(skull)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
return None;
};
let skin = skull.skin.as_ref()?;
if let Some((url, location)) = &skin.download {
self.skin_downloads
.borrow_mut()
.state(skins::Kind::Skin, url);
if self.gui.atlas.sprites.contains_key(location) {
// The texture a packed or resolved `textures` property fetches: the
// download of a static profile, or the one the services answer for a
// `Dynamic` (name- or id-only) profile.
let download = skin.download.clone().or_else(|| {
let key = skin.dynamic.as_ref()?;
let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key)
else {
return None;
};
let (value, signature) = profile.textures()?;
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
let location = skins::skin_location(&skins::texture_hash(&url)?);
Some((url, location))
});
if let Some((url, location)) = download {
let mut downloads = self.skin_downloads.borrow_mut();
if self.gui.atlas.sprites.contains_key(&location) {
let _ = downloads.state(skins::Kind::Skin, &url);
return Some(location);
}
// Its slot was evicted: offer the image again.
if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready {
downloads.again(&location);
}
}
Some(skin.fallback)
Some(skin.fallback.to_owned())
}
}
+1 -1
View File
@@ -862,7 +862,7 @@ impl Game<'_> {
let a = alpha as f32;
let range = super::entity_culling::default_range(
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
self.settings.entity_distance,
self.view_scale(),
);
for b in &self.boats {
let entity_start = out.len();
+90 -23
View File
@@ -8,7 +8,7 @@ use horizon_client::{
containers::{BlockEntity, Lectern},
first_person::Hand,
hud::{HudBatch, rgba},
item_data::WrittenBook,
item_data::{StackData, WrittenBook},
items::ItemStack,
menu::{Kind, Menu},
text::Text,
@@ -59,14 +59,86 @@ pub(super) enum BookScreen {
page_edit: ui::EditBox,
},
View {
pages: Vec<String>,
pages: Vec<Text>,
page: usize,
lectern: Option<(i32, i32, i32)>,
},
}
/// The pages a book screen reads: a written book's components (so their
/// click events stay), else a writable book's text.
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
let Some(data) = data else { return Vec::new() };
let Some(w) = &data.written else {
return data.writable_pages.iter().map(Text::literal).collect();
};
let tagged = w.page_tags.len() == w.pages.len();
w.pages
.iter()
.enumerate()
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
horizon_client::entity_nbt::name_text(tag)
}
_ => Text::literal(page),
})
.collect()
}
impl Game<'_> {
fn book_lines(&self, page: &str) -> Vec<Text> {
self.gui.atlas.font.split(&Text::literal(page), 114.0)
self.page_lines(&Text::literal(page))
}
fn page_lines(&self, page: &Text) -> Vec<Text> {
self.gui.atlas.font.split(page, 114.0)
}
/// `BookViewScreen.mouseClicked`: the click event of the text under the
/// cursor on the page.
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
let (mx, my) = self.cursor;
let row = ((my - 32.0) / 9.0).floor();
if row < 0.0 || row >= LINES as f32 {
return None;
}
let lines = self.page_lines(page);
let line = lines.get(row as usize)?.resolve_legacy();
let mut x = mx - (left + 36.0);
if x < 0.0 {
return None;
}
for (c, style) in line.chars() {
x -= self.gui.atlas.font.advance(c, style);
if x < 0.0 {
return style.click.clone();
}
}
None
}
/// `BookViewScreen.handleClickEvent`: a page link turns to its page
/// (`forcePage`), a web link goes through the confirm-link flow.
fn book_follow_link(
&mut self,
link: horizon_client::text::ClickEvent,
count: usize,
lectern: Option<(i32, i32, i32)>,
) {
use horizon_client::text::ClickEvent;
match link {
ClickEvent::ChangePage(target) => {
let target = (target - 1).clamp(0, count.saturating_sub(1) as i32);
let Some(BookScreen::View { page, .. }) = self.book.as_mut() else {
return;
};
if let Some(key) = lectern {
// LecternScreen.forcePage: the menu's button 100 + page.
if target != *page as i32 {
self.lectern_button(key, 100 + target);
}
} else if target != *page as i32 {
*page = target as usize;
}
}
ClickEvent::OpenUrl(url) => self.click_url(url),
_ => {}
}
}
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
/// A server's player writes in its own client's editor
@@ -115,11 +187,7 @@ impl Game<'_> {
}
}
"minecraft:written_book" => BookScreen::View {
pages: stack
.data()
.and_then(|d| d.written.as_ref())
.map(|w| w.pages.clone())
.unwrap_or_default(),
pages: view_pages(stack.data()),
page: 0,
lectern: None,
},
@@ -215,6 +283,7 @@ impl Game<'_> {
author,
generation: 0,
pages,
page_tags: Vec::new(),
});
*stack = written;
} else {
@@ -685,11 +754,7 @@ impl Game<'_> {
return false;
};
let Some(book) = &l.book else { return false };
let data = book.data();
let pages = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
.or_else(|| data.map(|d| d.writable_pages.clone()))
.unwrap_or_default();
let pages = view_pages(book.data());
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
self.stats.custom("interact_with_lectern", 1);
self.open_book(BookScreen::View {
@@ -811,11 +876,7 @@ impl Game<'_> {
page,
}) = self.book.as_mut()
{
let data = book.as_ref().and_then(ItemStack::data);
*pages = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
.or_else(|| data.map(|d| d.writable_pages.clone()))
.unwrap_or_default();
*pages = view_pages(book.as_ref().and_then(ItemStack::data));
*page = (*page).min(pages.len().saturating_sub(1));
}
}
@@ -955,7 +1016,10 @@ impl Game<'_> {
lectern,
}) => {
let (page, count, lectern) = (*page, pages.len(), *lectern);
if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
// The text's click event first, then the buttons.
if let Some(link) = pages.get(page).and_then(|t| self.book_link_at(t, left)) {
self.book_follow_link(link, count, lectern);
} else if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
self.turn_page(true);
} else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) {
self.turn_page(false);
@@ -1009,9 +1073,12 @@ impl Game<'_> {
}
};
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
let text = tr("book.pageIndicator", "Page %s of %s")
.replacen("%s", &(page + 1).to_string(), 1)
.replacen("%s", &count.max(1).to_string(), 1);
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
// positional ("Page %1$s of %2$s").
let text = horizon_protocol::text::format_java(
&tr("book.pageIndicator", "Page %1$s of %2$s"),
&[(page + 1).to_string(), count.max(1).to_string()],
);
let tw = HudBatch::text_width(atlas, &text);
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
};
@@ -1195,7 +1262,7 @@ impl Game<'_> {
} => {
indicator(batch, *page, pages.len());
if let Some(text) = pages.get(*page) {
for (i, line) in self.book_lines(text).iter().take(LINES).enumerate() {
for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() {
batch.text_styled(
atlas,
line,
+103
View File
@@ -0,0 +1,103 @@
//! `BundleMouseActions`: the wheel over a bundle in a container screen selects
//! one of the items it shows, which a click that takes one out of it then takes
//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the
//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`).
use super::{Game, Screen};
use horizon_client::{
bundle::{Contents, toggle_selected},
mouse::ScrollWheelHandler,
};
use horizon_protocol::packets::play::{self, Serverbound as S};
#[derive(Default)]
pub(super) struct BundleMouse {
wheel: ScrollWheelHandler,
/// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`).
hovered: Option<usize>,
}
impl Game<'_> {
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
/// container's own slots. A server only knows these in the inventory and chest-like menus.
fn bundle_slot(&mut self, slot: usize) -> bool {
(slot < 36 || slot == 504 || (300..500).contains(&slot))
&& (self.remote.is_none() || self.remote_storage_open())
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
}
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
/// through the items its tooltip shows. True when the bundle took the wheel.
pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool {
let Some(slot) = self.inventory_target().0 else {
return false;
};
if !self.bundle_slot(slot) {
return false;
}
let stack = self.slot_mut(slot).clone();
let items = self.res.items();
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
return false;
};
let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count());
if shown == 0 {
return false;
}
let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y);
let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y };
if wheel != 0 {
let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0);
let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32);
if next != selected {
self.select_bundle(slot, next);
}
}
true
}
/// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there.
fn select_bundle(&mut self, slot: usize, index: i32) {
if let Some(stack) = self.slot_mut(slot) {
toggle_selected(stack, index);
}
if self.remote.is_some() {
let (_, menu) = self.remote_menu();
if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) {
self.send(S::SelectBundleItem(play::SelectBundleItem {
slot: menu_slot as i32,
index,
}));
}
}
}
/// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection.
pub(super) fn unselect_bundle(&mut self, slot: usize) {
if !self.bundle_slot(slot) {
return;
}
let selected = self
.slot_mut(slot)
.as_ref()
.and_then(|s| s.data())
.is_some_and(|d| d.bundle_selected.0 >= 0);
if selected {
self.select_bundle(slot, -1);
}
}
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
/// screen, clears the bundle's selection.
pub(super) fn tick_bundle_hover(&mut self) {
let now = if self.screen == Screen::Inventory {
self.inventory_target().0
} else {
None
};
if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now)
&& Some(left) != now
{
self.unselect_bundle(left);
}
}
}
@@ -0,0 +1,138 @@
//! `CatSpawner`: every 1200 ticks a cat may appear near a random player,
//! in a village with enough occupied beds or inside a swamp hut.
use super::{
Dim, Game,
raids::{section, sections_to_village},
};
use horizon_client::mobs::{Species, spawn_rules};
use horizon_core::{geometry::Vec3, random::RandomSource};
const TICK_DELAY: i32 = 1200;
impl Game<'_> {
/// `CatSpawner.tick` (the overworld's custom spawner).
pub(super) fn tick_cat_spawner(&mut self) {
if self.dim != Dim::Overworld {
return;
}
self.creatures.cat_tick -= 1;
if self.creatures.cat_tick > 0 {
return;
}
self.creatures.cat_tick = TICK_DELAY;
// ServerLevel.getRandomPlayer: one of the living players.
let alive: Vec<Vec3> = self
.level_players()
.into_iter()
.filter(|p| p.alive)
.map(|p| p.position)
.collect();
if alive.is_empty() {
return;
}
let pick = self
.world
.random
.next_int_bound(alive.len() as i32)
.unwrap_or(0);
let p = alive[pick as usize];
let mut offset = || {
let n = 8 + self.world.random.next_int_bound(24).unwrap_or(0);
if self.world.random.next_boolean() {
-n
} else {
n
}
};
let x = offset();
let z = offset();
let pos = [
p.x.floor() as i32 + x,
p.y.floor() as i32,
p.z.floor() as i32 + z,
];
// hasChunksAt(pos +- 10)
let loaded = ((pos[0] - 10) >> 4..=(pos[0] + 10) >> 4).all(|cx| {
((pos[2] - 10) >> 4..=(pos[2] + 10) >> 4)
.all(|cz| self.world.chunks.contains_key(&(cx, cz)))
});
if !loaded {
return;
}
let ok = spawn_rules::is_spawn_position_ok(
Species::Cat,
&self.mob_world_base(&[]),
pos[0],
pos[1],
pos[2],
);
if !ok {
return;
}
if sections_to_village(&self.village_centers(), section(pos)) <= 2 {
self.spawn_cat_in_village(pos);
} else {
let hut = self.structure_tag("minecraft:cats_spawn_in");
if self.structures_at(pos).iter().any(|s| hut.contains(s)) {
self.spawn_cat_in_hut(pos);
}
}
}
/// `spawnInVillage`: more than four occupied beds within 48 blocks and
/// fewer than five cats around.
fn spawn_cat_in_village(&mut self, pos: [i32; 3]) {
// PoiManager.getCountInRange(HOME, IS_OCCUPIED): beds villagers have claimed.
let mut beds: Vec<[i32; 3]> = self
.mobs
.iter()
.filter(|m| m.species() == Species::Villager && m.alive())
.filter_map(|m| m.extra.bed)
.filter(|b| {
let d = |i: usize| i64::from(b[i] - pos[i]);
d(0).abs() <= 48
&& d(2).abs() <= 48
&& d(0).pow(2) + d(1).pow(2) + d(2).pow(2) <= 48 * 48
})
.collect();
beds.sort_unstable();
beds.dedup();
if beds.len() > 4 && self.cats_near(pos, 48.0) < 5 {
self.spawn_cat(pos, false);
}
}
/// `spawnInHut`: no cat within 16 blocks; the cat stays.
fn spawn_cat_in_hut(&mut self, pos: [i32; 3]) {
if self.cats_near(pos, 16.0) == 0 {
self.spawn_cat(pos, true);
}
}
/// Cats in `AABB(pos).inflate(radius, 8, radius)`.
fn cats_near(&self, pos: [i32; 3], radius: f64) -> usize {
let [x, y, z] = pos.map(f64::from);
let min = Vec3::new(x - radius, y - 8.0, z - radius);
let max = Vec3::new(x + 1.0 + radius, y + 9.0, z + 1.0 + radius);
self.mobs
.iter()
.filter(|m| {
m.species() == Species::Cat && m.alive() && m.bounds().intersects_corners(min, max)
})
.count()
}
/// `spawnCat`: finalized for a natural spawn, black inside
/// `#cats_spawn_as_black` structures (`Cat.finalizeSpawn`).
fn spawn_cat(&mut self, pos: [i32; 3], persistent: bool) {
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let Some(id) = self.spawn_mob("minecraft:cat", at, persistent) else {
return;
};
let black = self.structure_tag("minecraft:cats_spawn_as_black");
let in_black = self.structures_at(pos).iter().any(|s| black.contains(s));
if in_black && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.extra.variant = 10;
}
}
}
+92 -36
View File
@@ -106,6 +106,18 @@ pub struct UiState {
pub death_time: u32,
}
impl UiState {
/// The state a window starts with: the chat remembers the commands kept
/// in the game folder (`ChatComponent`'s constructor).
pub fn new() -> Self {
let mut ui = Self::default();
if let Ok(dir) = std::env::current_dir() {
ui.chat.load_history(&dir);
}
ui
}
}
impl Game<'_> {
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
pub(super) fn allow_commands(&self) -> bool {
@@ -695,12 +707,78 @@ impl Game<'_> {
Some(ClickEvent::OpenFile(path)) => {
super::packs::open_folder(std::path::Path::new(&path));
}
Some(ClickEvent::OpenUrl(url)) => self.click_url(url),
Some(ClickEvent::SuggestCommand(command)) => {
if let Some(screen) = self.ui.chat_screen.as_mut() {
screen.suggest_command(&m, &c, &command);
}
}
None => {}
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
Some(ClickEvent::ChangePage(_)) | None => {}
}
}
/// `Screen.clickUrlAction`: nothing with Web Links off, else the link
/// opens, after the confirmation unless Prompt on Links is off.
pub(super) fn click_url(&mut self, url: String) {
if !self.settings.chat_links {
return;
}
if self.settings.file.bool("chatLinksPrompt") {
self.link_prompt = Some(url);
} else {
super::packs::open_uri(&url);
}
}
/// `ConfirmLinkScreen` (untrusted): the link and its warning over the
/// chat, answered yes to open it.
fn link_prompt_layout(
&self,
url: &str,
size: (f32, f32),
) -> horizon_client::confirm_screen::Confirm {
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
let message = Text::literal(url)
.append(Text::literal("\n"))
.append(warning);
horizon_client::confirm_screen::Confirm::new(
&self.gui.atlas.font,
size,
&Text::translate("chat.link.confirm", &[]),
&message,
)
}
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
let Some(url) = &self.link_prompt else {
return;
};
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let (yes, no) = (
Text::translate("gui.yes", &[]),
Text::translate("gui.no", &[]),
);
self.link_prompt_layout(url, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
}
/// The prompt's answer: the link opens on yes; either way the chat is back.
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
if let Some(url) = self.link_prompt.take()
&& yes
{
super::packs::open_uri(&url);
}
}
pub(super) fn link_prompt_click(&mut self) {
let Some(url) = &self.link_prompt else {
return;
};
let answer = self
.link_prompt_layout(url, self.gui_size())
.answer_at(self.cursor);
if let Some(yes) = answer {
self.audio.play_button_click();
self.answer_link_prompt(yes);
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
@@ -789,6 +867,11 @@ impl Game<'_> {
pub(super) fn sync_rules(&mut self) {
self.world.health_rules.natural_regeneration =
self.ui.rules.bool("natural_health_regeneration");
// Player.isInvulnerableTo: the damage-type rules.
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
use horizon_world::food::Difficulty;
self.world.health_rules.difficulty = match self.ui.difficulty {
0 => Difficulty::Peaceful,
@@ -801,7 +884,11 @@ impl Game<'_> {
pub(super) fn load_world_state(&mut self) {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.clock = WorldClock::default();
self.ui.difficulty = read_difficulty(&self.world_root).unwrap_or(2);
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = horizon_client::title::DIFFICULTIES
.iter()
.position(|d| *d == self.level.difficulty)
.map_or(2, |i| i as u8);
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
&& let Ok((root, _)) =
@@ -826,9 +913,9 @@ impl Game<'_> {
self.build_registries();
self.sync_rules();
}
/// Save the clock and the difficulty beside level.dat (Level.write saves
/// the game rules: a second writer of game_rules.dat could leave it with
/// the rules loaded when the world opened).
/// Save the clock beside level.dat (Level.write saves the game rules: a
/// second writer of game_rules.dat could leave it with the rules loaded
/// when the world opened).
pub(super) fn save_world_state(&mut self) {
if self.world_root.as_os_str().is_empty() {
return;
@@ -876,9 +963,6 @@ impl Game<'_> {
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
@@ -988,34 +1072,6 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
n += 1;
}
}
fn read_difficulty(world: &std::path::Path) -> Option<u8> {
let bytes = std::fs::read(world.join("level.dat")).ok()?;
let raw = horizon_world::compression::Compression::Gzip
.decode(&bytes, 64 << 20)
.ok()?;
let (root, _) =
horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?;
match root.tag.get("Data")?.get("Difficulty")? {
horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8),
_ => None,
}
}
/// Patch `Data.Difficulty` in level.dat (Level::write stores the default).
fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> {
use horizon_world::{compression::Compression, nbt};
let path = world.join("level.dat");
let bytes = std::fs::read(&path)?;
let raw = Compression::Gzip.decode(&bytes, 64 << 20)?;
let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?;
if let Some(data) = root.tag.get_mut("Data")
&& let Some(d) = data.get_mut("Difficulty")
{
*d = nbt::Tag::Byte(difficulty as i8);
}
let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?;
std::fs::write(&path, Compression::Gzip.encode(&out)?)?;
Ok(())
}
/// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when
/// commands are allowed, else `ALL` (0).
@@ -0,0 +1,178 @@
//! Chiseled bookshelves: `ChiseledBookShelfBlock.useItemOn` / `useWithoutItem`
//! (a book into the slot under the crosshair, or out of it) and
//! `ChiseledBookShelfBlockEntity.updateState`, which also follows the books a
//! hopper or dropper moved (`machines::container_changed`). The slots' books
//! show through the block state's `slot_N_occupied`, which reaches a server's
//! clients with the block update.
use super::{Game, Hit, machines::dir_from_name};
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Who};
use horizon_core::{
geometry::{Direction, Vec3},
position::BlockPos,
};
use horizon_world::level::{flags, hit_slot};
const BLOCK: &str = "minecraft:chiseled_bookshelf";
impl Game<'_> {
/// `useItemOn` with `held` in the acting hand, then `useWithoutItem` on
/// the main hand's turn: None when the block passes the click, else
/// whether the use succeeded and the arm swings (a click on an empty slot
/// without a book only consumes it).
pub(super) fn use_chiseled_bookshelf(
&mut self,
hit: &Hit,
held: Option<&ItemStack>,
) -> Option<bool> {
let pos = hit.pos;
let state = self.world.block(pos[0], pos[1], pos[2]);
let info = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name == BLOCK)?;
let facing = info
.properties
.get("facing")
.and_then(|f| dir_from_name(f))?;
let face = Direction::ALL
.into_iter()
.find(|d| d.normal() == hit.face)?;
let at = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
let slot = hit_slot(
facing,
face,
BlockPos::new(pos[0], pos[1], pos[2]),
at,
2,
3,
)?;
let occupied = info
.properties
.get(&format!("slot_{slot}_occupied"))
.map(String::as_str)
== Some("true");
let book = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:bookshelf_books")
.is_some_and(|m| m.contains(&*s.item))
});
let centre = pos.map(|v| f64::from(v) + 0.5);
if let Some(book) = book
&& !occupied
{
// addBook: one book goes in (a creative player keeps theirs).
let book = book.clone();
self.stats.add("used", &book.item, 1);
let sound = if &*book.item == "minecraft:enchanted_book" {
"minecraft:block.chiseled_bookshelf.insert.enchanted"
} else {
"minecraft:block.chiseled_bookshelf.insert"
};
self.bookshelf_items(pos)?[slot] = Some(ItemStack {
count: 1,
..book.clone()
});
self.consume_held();
self.chiseled_bookshelf_update_state(pos, slot);
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.trigger_block("minecraft:item_used_on_block", pos, &book);
return Some(true);
}
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != horizon_client::first_person::Hand::Main {
return None;
}
if !occupied {
return Some(false);
}
// removeBook: the book goes to the inventory, or is dropped.
let retrieved = self.bookshelf_items(pos)?[slot].take()?;
self.chiseled_bookshelf_update_state(pos, slot);
let sound = if &*retrieved.item == "minecraft:enchanted_book" {
"minecraft:block.chiseled_bookshelf.pickup.enchanted"
} else {
"minecraft:block.chiseled_bookshelf.pickup"
};
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.give_or_drop(retrieved);
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
Some(true)
}
/// The bookshelf's six slots (made if the block has no block entity yet).
fn bookshelf_items(&mut self, pos: [i32; 3]) -> Option<&mut [Option<ItemStack>; 6]> {
let entity = self
.world
.block_entities
.get_or_insert_with((pos[0], pos[1], pos[2]), || {
BlockEntity::ChiseledBookshelf(Box::default())
});
match entity {
BlockEntity::ChiseledBookshelf(b) => Some(&mut b.items),
_ => None,
}
}
/// `ChiseledBookShelfBlockEntity.updateState`: `slot` becomes the last
/// one touched, the block state takes the slots' occupancy
/// (`setBlockAndUpdate`) and `BLOCK_CHANGE` goes out.
fn chiseled_bookshelf_update_state(&mut self, pos: [i32; 3], slot: usize) {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get_mut(&key)
else {
return;
};
b.last_slot = slot as i32;
let occupied: Vec<bool> = b.items.iter().map(Option::is_some).collect();
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.cloned()
else {
return;
};
for (i, occupied) in occupied.into_iter().enumerate() {
state
.properties
.insert(format!("slot_{i}_occupied"), occupied.to_string());
}
let Some(new) = registry.id(&state) else {
return;
};
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, new, flags::UPDATE_ALL));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
/// The books a hopper or dropper put in or took out: the slot whose
/// occupancy no longer matches the block state is the one `setItem` or
/// `removeItem` updated.
pub(super) fn chiseled_bookshelf_synced(&mut self, pos: (i32, i32, i32)) {
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get(&pos) else {
return;
};
let Some(info) = self
.pack
.assets
.registry
.state(self.world.block(pos.0, pos.1, pos.2))
else {
return;
};
let moved = (0..6).rev().find(|i| {
let shown = info
.properties
.get(&format!("slot_{i}_occupied"))
.map(String::as_str);
b.items[*i].is_some() != (shown == Some("true"))
});
if let Some(slot) = moved {
self.chiseled_bookshelf_update_state([pos.0, pos.1, pos.2], slot);
}
}
}
@@ -4,8 +4,12 @@
//! on the game that has the window, in-frame or following a server, never on
//! a server: nothing of them goes over the network.
use super::Game;
use horizon_client::blocks::RenderShape;
use horizon_client::mobs::{MobEvent, ambient};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::motion::{LAVA, WATER};
use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType};
use horizon_client::pose::Pose;
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
pub(super) fn tick_client_ambient(&mut self) {
@@ -13,8 +17,10 @@ impl Game<'_> {
return;
}
let mut events = Vec::new();
let random = self.particles.level_random();
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
ambient::client_ai_step(m, &mut events);
ambient::client_particles(m, random, &mut events);
}
for event in events {
match event {
@@ -33,5 +39,210 @@ impl Game<'_> {
}
}
self.eye_particles();
self.panda_effects();
self.sprint_dust();
self.effect_swirls();
}
/// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the
/// block under a sprinting player's feet streams back from them every
/// tick, for the window's own player and for the other players it sees.
fn sprint_dust(&mut self) {
// Each runner's feet, movement and supporting block.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
// Entity.canSpawnSprintParticle.
if let Some(p) = self.player.as_ref()
&& p.sprinting
&& !p.spectator
&& p.pose != Pose::Crouching
&& !p.health.dead_or_dying()
&& !p.fluids.was_touching_water
&& !p.is_in_lava()
&& p.fluid_height(WATER) <= 0.0
&& p.fluid_height(LAVA) <= 0.0
{
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
runners.push((p.position, p.velocity, support));
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
// Another player's fluids aren't known here: none where they stand.
let feet = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
if m.extra.sprinting
&& !m.extra.crouching
&& m.alive()
&& &*m.kind.id == "minecraft:player"
&& self
.res
.facts()
.get(self.world.block(feet[0], feet[1], feet[2]))
.fluid_level
== 0
{
runners.push((m.position, m.position.subtract(m.previous), None));
}
}
for (position, movement, support) in runners {
// Entity.getOnPosLegacy: getOnPos(0.2).
let below = (position.y - f64::from(0.2f32)).floor() as i32;
let tall = |game: &Self, s: [i32; 3]| {
let name = game.block_name(s);
[
"minecraft:fences",
"minecraft:walls",
"minecraft:fence_gates",
]
.iter()
.any(|tag| game.block_in_tag(tag, &name))
};
let at = match support {
Some(s) if tall(self, s) => s,
Some(s) => [s[0], below, s[2]],
None => [position.x.floor() as i32, below, position.z.floor() as i32],
};
let state = self.world.block(at[0], at[1], at[2]);
if self.res.facts().get(state).render_shape == RenderShape::Invisible {
continue;
}
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let width = f64::from(0.6f32);
let r = self.particles.random();
let mut x = position.x + (r.next_double() - 0.5) * width;
let mut z = position.z + (r.next_double() - 0.5) * width;
// Clamped into the block when the feet are in another column.
if position.x.floor() as i32 != at[0] {
x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0);
}
if position.z.floor() as i32 != at[2] {
z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0);
}
self.particles.add(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
[x, position.y + 0.1, z],
[movement.x * -4.0, 1.5, movement.z * -4.0],
);
}
}
/// `LivingEntity.tickEffects` on a client: the swirls around an entity
/// with visible effects, from the particles its synched data lists. The
/// window's player has its own effects, a server's mobs and players the
/// list the server sent, and the mobs and guests of a game in the frame
/// theirs.
fn effect_swirls(&mut self) {
if let Some(p) = self.player.as_ref() {
let effects = &self.potions.effects.list;
let list: Vec<_> = effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| effect_particle(e.id, e.ambient))
.collect();
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
let invisible = effects.iter().any(|e| e.id == "invisibility");
let size = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
swirl(
&mut self.particles,
p.position,
size,
&list,
ambient,
invisible,
);
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
if !m.alive() {
continue;
}
let size = (f64::from(m.width()), f64::from(m.height()));
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
if m.extra.effect_particles.is_empty() {
// A mob of this game: its own effects, visible and not ambient.
if m.extra.effects.is_empty() {
continue;
}
let list: Vec<_> = m
.extra
.effects
.iter()
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.filter_map(|e| effect_particle(e.0, false))
.collect();
swirl(
&mut self.particles,
m.position,
size,
&list,
false,
invisible,
);
} else {
let list = &m.extra.effect_particles;
let ambient = m.extra.effect_ambient;
swirl(
&mut self.particles,
m.position,
size,
list,
ambient,
invisible,
);
}
}
}
}
/// `MobEffect.particleFactory`: the omens have particles of their own, any
/// other effect an entity effect in its colour, faint when ambient.
pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option<ParticleOptions> {
Some(match effect.trim_start_matches("minecraft:") {
"trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen),
"raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen),
other => {
let color = horizon_client::brewing::effect(other)?.color;
let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0;
// MobEffect.AMBIENT_ALPHA is 38 of 255.
let alpha = if ambient { 38.0 / 255.0 } else { 1.0 };
ParticleOptions::colored(
ParticleType::EntityEffect,
[channel(16), channel(8), channel(0), alpha],
)
}
})
}
/// One tick of `LivingEntity.tickEffects`' client branch: a one in four
/// chance (one in fifteen while invisible, a fifth of either when all the
/// effects are ambient) of one of the particles at a random spot of the box.
fn swirl(
particles: &mut ParticleEngine,
feet: Vec3,
size: (f64, f64),
list: &[ParticleOptions],
ambient: bool,
invisible: bool,
) {
if list.is_empty() {
return;
}
let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 };
let r = particles.random();
let Ok(0) = r.next_int_bound(bound) else {
return;
};
// Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ.
let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize;
let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
let y = feet.y + size.1 * r.next_double();
let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
particles.add(&list[pick], [x, y, z], [1.0; 3]);
}
+100 -3
View File
@@ -1,7 +1,16 @@
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
//! where the player fell from, turned into the vanilla death message.
use super::{Game, chat::PLAYER_NAME};
use horizon_client::{health::HealthEvent, motion::MotionEvent, text::Text};
use horizon_client::{
health::HealthEvent,
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::random::RandomSource;
/// `EntityEvent.DROWN_PARTICLES`: broadcast with the drowning damage.
pub(super) const DROWN_EVENT: i8 = 67;
struct Entry {
damage_type: String,
@@ -10,6 +19,9 @@ struct Entry {
fall_distance: f32,
/// The causing entity: its id (for comparisons) and display name.
attacker: Option<(u32, Text)>,
/// The direct entity's display name when no entity caused the damage
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
direct: Option<Text>,
}
#[derive(Default)]
pub(super) struct CombatTracker {
@@ -19,6 +31,8 @@ pub(super) struct CombatTracker {
/// Attackers of damage dealt this tick, claimed by the matching
/// `RecordedDamage` event.
pending: Vec<(String, (u32, Text))>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
last_hurt_by: Option<((u32, Text), i64)>,
/// The mob the player last hurt (`getLastHurtMob`) and when.
@@ -31,6 +45,13 @@ pub(super) struct CombatTracker {
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// The blast now going off comes from a primed TNT, creeper, wither
/// skull, wither or TNT minecart, the sources that empty a hive they
/// destroy (`BeehiveBlock.getDrops`).
pub(super) hive_emptying_blast: bool,
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
/// the mobs the blast hurts record it as their attacker.
pub(super) explosion_owner: Option<u32>,
/// Charged creepers whose blast just went off and that have not dropped
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
/// but the mobs they killed still ask for their loot.
@@ -40,6 +61,38 @@ pub(super) struct CombatTracker {
fall_before: f64,
}
impl Game<'_> {
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for _ in 0..8 {
let mut triangle = || {
let r = self.particles.random();
r.next_double() - r.next_double()
};
let p = [at[0] + triangle(), at[1] + triangle(), at[2] + triangle()];
self.particles.add(&bubble, p, velocity);
}
}
/// Entity event 67 from a server, for this client's own player or an
/// entity it draws.
pub(super) fn remote_drown(&mut self, id: i32) {
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let body = if own {
self.player.as_ref().map(|p| {
let (at, v) = (p.position, p.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
let (at, v) = (m.position, m.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
};
if let Some((at, v)) = body {
self.drown_particles(at, v);
}
}
/// The display name of a mob: its custom name or its type's name.
pub(super) fn mob_display_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
@@ -66,17 +119,38 @@ impl Game<'_> {
.pending
.push((damage_type.to_owned(), (id, name)));
}
/// The `DamageSource.directEntity` of the next `damage_type` damage when
/// nothing caused it (a splash potion nobody threw): the death message
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
self.combat
.pending_direct
.push((damage_type.to_owned(), name));
}
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
let pitch = self.audio.hurt_pitch();
self.audio.play(sound, Some(at), 1.0, pitch);
// Player.getVoicePitch: always 1.0.
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// `CombatTracker.recordDamage` for the motion events of this tick.
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
self.recheck_combat();
for e in events {
let (name, amount) = match e {
// LivingEntity.getDamageAfterMagicAbsorb (Resistance) and
// Player.actuallyHurt (absorption hearts) and
// LivingEntity.hurtServer (the shield, only for an accepted
// hit), in tenths.
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
match *name {
"resisted" => self.stats.custom("damage_resisted", *amount),
"absorbed" => self.stats.custom("damage_absorbed", *amount),
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
_ => {}
}
continue;
}
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
let Some(d) = self.motion.health.damage.definition(*source) else {
continue;
@@ -94,6 +168,15 @@ impl Game<'_> {
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending.remove(i).1);
let direct = match attacker {
Some(_) => None,
None => self
.combat
.pending_direct
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending_direct.remove(i).1),
};
let (fall_location, fall_distance) = self.fall_location();
let now = self.world.game_time;
if let Some(a) = &attacker {
@@ -111,10 +194,16 @@ impl Game<'_> {
fall_location,
fall_distance,
attacker,
direct,
});
self.combat.last_damage = now;
// InfestedMobEffect.onMobHurt.
if self.potions.effects.get("infested").is_some() {
self.player_infested();
}
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
}
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
@@ -224,6 +313,7 @@ impl Game<'_> {
let entries = std::mem::take(&mut self.combat.entries);
let killing = entries.last().expect("checked above");
let killer = killing.attacker.clone();
let direct = killing.direct.clone();
if def.1 == "fall_variants"
&& let Some(knock) = knock.and_then(|i| entries.get(i))
{
@@ -248,15 +338,22 @@ impl Game<'_> {
.append(Text::literal("]"));
return Text::translate(&format!("{key}.message"), &[me, link]);
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((_, name)) = killer {
return Text::translate(&key, &[me, name]);
}
if let Some(name) = direct {
return Text::translate(&key, &[me, name]);
}
// getKillCredit: the last mob that hurt the player in the past 100 ticks.
if let Some(((_, name), at)) = &self.combat.last_hurt_by
&& self.world.game_time - at <= 100
{
return Text::translate(&format!("{key}.player"), &[me, name.clone()]);
}
// No entity at all: the one-argument message, as vanilla sends it (a
// template that names a killer then shows as written).
Text::translate(&key, &[me])
}
}
@@ -2,7 +2,7 @@
//! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand`
//! through the command system with the block's output, and
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
use super::{Game, Screen, commands::execute::Source};
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
use horizon_client::{
command_block::{self, CommandBlock, Mode},
containers::BlockEntity,
@@ -12,6 +12,7 @@ use horizon_client::{
world::World,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Serverbound as S};
use horizon_world::{
chunk::BlockState,
level::flags,
@@ -35,6 +36,16 @@ pub(super) struct CommandBlockScreen {
auto: bool,
}
/// What `ServerboundSetCommandBlockPacket` carries.
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum Button {
Output,
@@ -186,6 +197,18 @@ impl Game<'_> {
pub(super) fn can_use_game_master_blocks(&self) -> bool {
self.creative && self.ui.registries.permission >= 2
}
/// `CommandBlock.useWithoutItem` on a server's client, which opens the
/// screen itself (`Player.openCommandBlock`) while the server only
/// answers the click: the block at `pos`, when it is a command block the
/// player may edit.
pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) {
let is_command_block = self
.block_state_at(pos)
.is_some_and(|state| Mode::of_block(&state.name).is_some());
if is_command_block && self.can_use_game_master_blocks() {
self.open_command_block(pos);
}
}
fn command_block(&self, pos: Pos) -> Option<&CommandBlock> {
match self.world.block_entities.get(&pos)? {
BlockEntity::CommandBlock(c) => Some(c),
@@ -572,12 +595,66 @@ impl Game<'_> {
s.command.insert_text(&allowed);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock`.
/// The edit screen's Done: a server's client sends the packet, the
/// window's own world applies it (`handleSetCommandBlock`).
fn set_command_block(&mut self, s: &CommandBlockScreen) {
if !self.can_use_game_master_blocks() {
self.system_message(Text::translate("advMode.notAllowed", &[]));
return;
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value.clone(),
track_output: s.track_output,
mode: s.mode,
conditional: s.conditional,
auto: s.auto,
};
if self.remote.is_some() {
self.send(S::SetCommandBlock(play::SetCommandBlock {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
command: edit.command,
mode: edit.mode.ordinal(),
flags: i8::from(edit.track_output)
| (i8::from(edit.conditional) << 1)
| (i8::from(edit.auto) << 2),
}));
} else {
self.apply_command_block(&edit);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the
/// block is edited only by a creative player with game master commands.
pub(super) fn lan_set_command_block(
&mut self,
host: &mut LanHost,
conn: u32,
p: play::SetCommandBlock,
) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let allowed = g.creative() && g.permission >= 2;
let edit = CommandBlockEdit {
pos: (p.pos[0], p.pos[1], p.pos[2]),
command: p.command,
track_output: p.flags & 1 != 0,
mode: Mode::from_ordinal(p.mode),
conditional: p.flags & 2 != 0,
auto: p.flags & 4 != 0,
};
self.as_guest(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
});
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -609,7 +686,7 @@ impl Game<'_> {
}
let _ = self.process_level_events();
}
let command = s.command.value.clone();
let command = s.command.clone();
let Some(c) = command_block_mut(&mut self.world, pos) else {
return;
};
+234 -82
View File
@@ -42,9 +42,10 @@ pub(super) enum Target {
Guest(u32),
Mob(u32),
Item(u32),
Orb(usize),
/// By index in `creatures.projectiles`.
Projectile(usize),
/// An experience orb, by its entity id.
Orb(u32),
/// A projectile, by its entity id.
Projectile(u32),
Stand(u32),
Frame(u32),
Painting(u32),
@@ -53,6 +54,10 @@ pub(super) enum Target {
/// An entity of a type Horizon does not simulate (markers, displays,
/// interactions...): kept as its saved data at a position.
Other(u32),
/// An End crystal, by its entity id (`minecraft:end_crystal`).
Crystal(u32),
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
Dragon(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -340,14 +345,16 @@ impl Game<'_> {
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(i) => self
Target::Orb(id) => self
.orbs
.get(i)
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(i) => self
Target::Projectile(id) => self
.creatures
.projectiles
.get(i)
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
@@ -385,6 +392,16 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
@@ -399,10 +416,11 @@ impl Game<'_> {
}),
Target::Item(_) => "minecraft:item".into(),
Target::Orb(_) => "minecraft:experience_orb".into(),
Target::Projectile(i) => self
Target::Projectile(id) => self
.creatures
.projectiles
.get(i)
.iter()
.find(|p| p.id == id)
.map_or_else(String::new, |p| p.kind.id().into()),
Target::Stand(_) => "minecraft:armor_stand".into(),
Target::Frame(id) => {
@@ -428,6 +446,8 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map_or_else(String::new, |o| o.kind.clone()),
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -512,51 +532,72 @@ impl Game<'_> {
s.pos[2].unwrap_or(p[2]),
]
};
let mut candidates: Vec<Target> = Vec::new();
let mut source_candidates: Vec<Target> = Vec::new();
match (s.kind, self.command_source) {
// @s: the executing entity (none for a server source).
('s', Some(source)) => candidates.extend(source.entity),
('s', None) => candidates.extend(self.source_entity()),
('s', Some(source)) => source_candidates.extend(source.entity),
('s', None) => source_candidates.extend(self.source_entity()),
// A guest in place is among the players, in join order.
_ if self.player.is_some() && self.lan_acting.is_none() => {
candidates.push(Target::Player);
source_candidates.push(Target::Player);
}
_ => {}
}
if s.kind != 's' {
use horizon_client::projectiles::ProjectileKind as K;
candidates.extend(self.player_candidates(s));
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend((0..self.orbs.len()).map(Target::Orb));
candidates.extend(
self.creatures
.projectiles
.iter()
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
source_candidates.extend(self.player_candidates(s));
}
let source_dim = self.dim;
// A selector with a spatial constraint reaches the level the command
// runs in only; an unbounded one reaches every loaded level
// (EntitySelector.findEntities).
let dims = if s.world_limited() {
vec![source_dim]
} else {
self.loaded_dims()
};
let mut out: Vec<(Target, f64)> = Vec::new();
for dim in dims {
let back = self.enter_command_level(dim);
let mut candidates = Vec::new();
if dim == source_dim {
candidates.append(&mut source_candidates);
}
if s.kind != 's' {
candidates.extend(self.level_candidates());
}
out.extend(self.select_in_level(candidates, s, origin));
if let Some(back) = back {
self.enter_command_level(back);
}
}
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
}
Sort::Arbitrary => {}
}
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
/// The candidates of the level in place that pass the selector
/// (EntitySelector.getPredicate).
fn select_in_level(
&mut self,
candidates: Vec<Target>,
s: &Selector,
origin: [f64; 3],
) -> Vec<(Target, f64)> {
let level = self.experience.level;
let creative = self.creative;
let passing = self.selector_extra_filter(&candidates, s);
let mut out: Vec<(Target, f64)> = candidates
candidates
.into_iter()
.filter_map(|t| {
let p = self.target_position(t)?;
@@ -628,23 +669,102 @@ impl Game<'_> {
}
Some((t, d2))
})
.collect();
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
}
Sort::Arbitrary => {}
}
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
/// The entities of the level in place a selector can find.
fn level_candidates(&self) -> Vec<Target> {
use horizon_client::projectiles::ProjectileKind as K;
let mut candidates = Vec::new();
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
candidates.extend(
self.creatures
.projectiles
.iter()
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|p| Target::Projectile(p.id)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
// The End crystals and the dragon (EndCrystal and EnderDragon
// are entities of their own).
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
if let Some(d) = &self.dragon {
candidates.push(Target::Dragon(d.id));
}
candidates
}
/// The loaded levels, in the order the server holds them: the
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
fn loaded_dims(&self) -> Vec<Dim> {
let mut dims = vec![self.dim];
dims.extend(self.levels.parked_dims());
dims.sort_by_key(|d| match d {
Dim::Overworld => 0,
Dim::Nether => 1,
Dim::End => 2,
});
dims
}
/// Whether the level in place holds the entity a target names.
fn level_holds(&self, t: Target) -> bool {
match t {
// A player is in no level of its own.
Target::Player | Target::Guest(_) => true,
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
Target::Other(id) => self.others.iter().any(|o| o.id == id),
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
/// (the entity ids are unique across the loaded levels) and put the
/// level back afterwards. `f` does not run when no level holds the
/// entity.
pub(in crate::game) fn in_target_level<R>(
&mut self,
t: Target,
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
if self.level_holds(t) {
return Some(f(self));
}
for dim in self.levels.parked_dims() {
let back = self.enter_command_level(dim);
if self.level_holds(t) {
let r = f(self);
if let Some(back) = back {
self.enter_command_level(back);
}
return Some(r);
}
if let Some(back) = back {
self.enter_command_level(back);
}
}
None
}
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
let path = self.options.resources.join(format!(
"data/minecraft/tags/entity_type/{}.json",
@@ -1104,6 +1224,10 @@ impl Game<'_> {
Ok(targets.len() as i32)
}
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
// An entity in another level is teleported in the level that holds it.
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
}
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
let v = Vec3::new(pos[0], pos[1], pos[2]);
match t {
Target::Guest(_) => {
@@ -1152,14 +1276,14 @@ impl Game<'_> {
i.previous = v;
}
}
Target::Orb(i) => {
if let Some(o) = self.orbs.get_mut(i) {
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.position = v;
o.previous = v;
}
}
Target::Projectile(i) => {
if let Some(a) = self.creatures.projectiles.get_mut(i) {
Target::Projectile(id) => {
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
a.position = v;
a.previous = v;
}
@@ -1199,6 +1323,17 @@ impl Game<'_> {
}
}
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
c.position = v;
}
}
Target::Dragon(id) => {
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
d.position = v;
d.previous = v;
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -1558,6 +1693,10 @@ impl Game<'_> {
Ok(duration.unwrap_or(-1))
}
fn kill_target(&mut self, t: Target) {
// An entity in another level is killed in the level that holds it.
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
}
fn kill_target_in_level(&mut self, t: Target) {
match t {
Target::Guest(_) => {
self.for_player(t, |g| g.kill_target(Target::Player));
@@ -1577,16 +1716,13 @@ impl Game<'_> {
}
}
Target::Item(id) => self.dropped.retain(|i| i.id != id),
Target::Orb(i) => {
if i < self.orbs.len() {
self.orbs[i].age = u32::MAX / 2;
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.age = u32::MAX / 2;
}
}
// cmd_kill goes from the highest index down.
Target::Projectile(i) => {
if i < self.creatures.projectiles.len() {
self.creatures.projectiles.remove(i);
}
Target::Projectile(id) => {
self.creatures.projectiles.retain(|p| p.id != id);
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
@@ -1594,7 +1730,11 @@ impl Game<'_> {
Target::Boat(id) => self.boats.retain(|b| b.id != id),
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
self.orbs.retain(|o| o.age < u32::MAX / 2);
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -1602,19 +1742,18 @@ impl Game<'_> {
} else {
vec![self.implied_player()]
};
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
// Remove indexed entities from the back so earlier indices stay valid.
let mut ordered = targets.clone();
ordered.sort_by_key(|t| {
std::cmp::Reverse(match t {
Target::Orb(i) | Target::Projectile(i) => *i,
_ => 0,
})
});
for t in ordered {
let mut names: Vec<Text> = Vec::new();
for &t in &targets {
// The name of an entity in another level is read there.
let name = match self.in_target_level(t, |g| g.target_name(t)) {
Some(name) => name,
None => self.target_name(t),
};
names.push(name);
}
for &t in &targets {
self.kill_target(t);
}
self.orbs.retain(|o| o.age < u32::MAX / 2);
let message = if targets.len() == 1 {
Text::translate("commands.kill.success.single", &[names[0].clone()])
} else {
@@ -1706,6 +1845,20 @@ impl Game<'_> {
self.spawn_bolt(pos, false);
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
}
"end_crystal" => {
// EndCrystal's constructor takes an id from the entity
// counter, and the crystal is shown with its base.
self.next_entity += 1;
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: Vec3::new(pos[0], pos[1], pos[2]),
show_bottom: true,
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
beam: None,
invulnerable: false,
});
name = Text::translate("entity.minecraft.end_crystal", &[]);
}
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
let cart_kind = super::minecarts::CartKind::from_id(&kind)
.unwrap_or(super::minecarts::CartKind::Rideable);
@@ -1896,8 +2049,7 @@ impl Game<'_> {
return false;
}
self.world.block_entities.insert(key, new);
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
true
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
@@ -31,8 +31,13 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
impl Game<'_> {
// ---- entity data ---------------------------------------------------
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
/// in another level is read in the level that holds it.
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
.flatten()
}
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
let player_uuid = self.level.player_uuid;
Some(match t {
Target::Guest(_) => {
@@ -113,9 +118,11 @@ impl Game<'_> {
let o = self.others.iter().find(|o| o.id == id)?.clone();
self.other_to_nbt(&o, uuid)
}
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
Target::Projectile(i) => {
let p = self.creatures.projectiles.get(i)?;
Target::Orb(id) => {
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
}
Target::Projectile(id) => {
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
} else {
@@ -129,6 +136,16 @@ impl Game<'_> {
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
Target::Dragon(id) => {
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
@@ -397,6 +414,19 @@ impl Game<'_> {
}
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
let target = self.data_ref(ctx, 0)?;
// An entity in another level is read and written in the level that
// holds it.
if let DataRef::Entity(t) = &target {
let t = *t;
let target = target.clone();
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
Some(r) => r,
None => Err(err("argument.entity.notfound.entity", &[])),
};
}
self.cmd_data_in(id, ctx, target)
}
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
match id {
"data.merge" => {
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
@@ -1,11 +1,10 @@
//! `/effect` (EffectCommands) and `/enchant` (EnchantCommand).
use super::{Arg, Context, Game, R, Target, err, texts};
use horizon_client::{
brewing,
brewing::{self, namespaced},
effects::EffectInstance,
text::{self, Style, Text},
};
use std::{collections::HashMap, sync::OnceLock};
/// `CommandResponseTracker` over targets: elements with a non-zero result.
#[derive(Default)]
@@ -21,24 +20,6 @@ impl Tracker {
}
}
}
/// Namespaced effect ids for mob effect storage.
fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
brewing::EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
fn effect_name(id: &str) -> Text {
Text::translate(&format!("effect.minecraft.{id}"), &[])
}
@@ -91,7 +72,12 @@ impl Game<'_> {
}
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
m.add_effect(namespaced(effect.id), duration, amplifier);
let name = namespaced(effect.id);
m.add_effect(name, duration, amplifier);
m.extra.hidden_effects.retain(|h| *h != name);
if !particles {
m.extra.hidden_effects.push(name);
}
true
}
None => false,
@@ -154,6 +140,10 @@ impl Game<'_> {
m.extra
.effects
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
let left = &m.extra.effects;
m.extra
.hidden_effects
.retain(|h| left.iter().any(|x| x.0 == *h));
m.extra.effects.len() != before
}
None => false,
@@ -640,6 +640,73 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
},
// Leashable.getLeashHolder: a fence knot is not an entity here.
Some("leasher") => match entity {
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
.and_then(|(holder, player)| match holder {
// The player who tied it (the host's when unrecorded).
horizon_client::mobs::leash::Holder::Player => {
let uuid = player.or(self.level.player_uuid)?;
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
}
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
horizon_client::mobs::leash::Holder::Knot(_) => None,
})
.into_iter()
.collect(),
_ => Vec::new(),
},
// TraceableEntity.getOwner: who shot a projectile, who threw an
// item, who summoned a vex.
Some("origin") => match entity {
Target::Projectile(id) => self
.creatures
.projectiles
.iter()
.find(|p| p.id == id)
.and_then(|p| match (p.owner?, p.guest) {
// A guest's shot: `Who::Player` is them.
(horizon_client::mobs::Who::Player, Some(conn))
if self.lan_acting == Some(conn) =>
{
Some(Target::Player)
}
(horizon_client::mobs::Who::Player, Some(conn)) => {
Some(Target::Guest(conn))
}
(horizon_client::mobs::Who::Player, None) => Some(Target::Player),
(horizon_client::mobs::Who::Mob(id), _) => Some(Target::Mob(id)),
})
.into_iter()
.collect(),
Target::Item(id) => self
.dropped
.iter()
.find(|d| d.id == id)
.and_then(|d| d.thrower)
.and_then(|words| {
let uuid = words
.iter()
.fold(0u128, |all, w| all << 32 | u128::from(*w as u32));
self.target_by_uuid(uuid)
})
.into_iter()
.collect(),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id && m.kind.id == "minecraft:vex")
.map(|m| m.extra.counter2 as u32)
.filter(|owner| *owner != 0)
.map(Target::Mob)
.into_iter()
.collect(),
_ => Vec::new(),
},
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
Some("controller") | Some("passengers") => match entity {
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
@@ -841,10 +908,12 @@ impl Game<'_> {
.coords(c, "pos")
.map(|co| co.block(self.source_position(), self.source_rotation()));
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
return Ok(flag(
dimension == self.dim
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
));
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
let loaded = |p: [i32; 3]| {
let key = (p[0] >> 4, p[2] >> 4);
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
};
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
}
if c.has("dimension") {
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
@@ -14,10 +14,11 @@ impl Game<'_> {
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
// NbtContents.resolve: the path's values in the entity, block or
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
// ScoreContents.resolve: the holder's score in the objective.
let nbt_values = std::cell::RefCell::new(Vec::new());
{
let mut collect = |o: &serde_json::Map<String, serde_json::Value>| {
if o.contains_key("nbt") {
if o.contains_key("nbt") || o.contains_key("score") {
nbt_values.borrow_mut().push(o.clone());
}
};
@@ -40,7 +41,11 @@ impl Game<'_> {
}
let mut resolved: Vec<(serde_json::Map<String, serde_json::Value>, Text)> = Vec::new();
for o in nbt_values.into_inner() {
let t = self.resolve_nbt_component(&o);
let t = if o.contains_key("score") {
self.resolve_score_component(&o)?
} else {
self.resolve_nbt_component(&o)
};
resolved.push((o, t));
}
let mut resolve = |raw: &str| -> Text {
@@ -73,6 +78,54 @@ impl Game<'_> {
},
))
}
/// The text of a `score` component (ScoreContents.resolve): the named
/// holder's score, `*` being the player the command runs for.
fn resolve_score_component(
&mut self,
o: &serde_json::Map<String, serde_json::Value>,
) -> R<Text> {
let score = o.get("score");
let field = |k: &str| score.and_then(|s| s.get(k)).and_then(|v| v.as_str());
let (name, objective) = (
field("name").unwrap_or(""),
field("objective").unwrap_or(""),
);
// findTargetName: a selector that matches one entity is that entity,
// one that matches none is just the name.
let mut reader = horizon_client::command::Reader::new(name);
let ty = horizon_client::command::args::ArgType::Entity {
single: false,
players: false,
};
let mut holder = name.to_owned();
if let Ok(Arg::Entity(sel)) = ty.parse(&mut reader, &self.ui.registries)
&& !reader.can_read()
{
let found = self.select(&sel);
if found.len() > 1 {
return Err(err("argument.entity.toomany", &[]));
}
if let Some(t) = found.first().and_then(|t| self.holder_name(*t)) {
holder = t;
}
}
if holder == "*" {
holder = self.acting_player_name().to_owned();
}
let board = &self.score.board;
let (Some(objective), Some(score)) =
(board.objective(objective), board.score(&holder, objective))
else {
return Ok(Text::empty());
};
// numberFormatOrDefault(StyledFormat.NO_STYLE).
let format = (score.number_format.clone())
.or_else(|| objective.number_format.clone())
.unwrap_or(horizon_client::scoreboard::NumberFormat::Styled(
serde_json::json!({}),
));
Ok(format.format(score.value))
}
/// The text of an `nbt` component.
fn resolve_nbt_component(&mut self, o: &serde_json::Map<String, serde_json::Value>) -> Text {
use horizon_world::nbt::Tag;
@@ -136,28 +189,40 @@ impl Game<'_> {
}
out
}
fn player_targets(&mut self, ctx: &Context) -> R<usize> {
// Only the local player can receive messages and titles.
/// The players the command reaches: the window's own and the LAN guests.
fn player_targets(&mut self, ctx: &Context) -> R<Vec<Target>> {
let targets = self.entities(ctx, "targets", true)?;
Ok(targets.iter().filter(|t| **t == Target::Player).count())
Ok(targets
.into_iter()
.filter(|t| matches!(t, Target::Player | Target::Guest(_)))
.collect())
}
pub(super) fn cmd_tellraw(&mut self, ctx: &Context) -> R<i32> {
let n = self.player_targets(ctx)?;
let players = self.player_targets(ctx)?;
let message = self.component_arg(ctx, "message")?;
if n > 0 {
self.system_message(message);
// Each player's chat (a guest's through the connection).
for &t in &players {
self.for_player(t, |g| g.system_message(message.clone()));
}
Ok(n as i32)
Ok(players.len() as i32)
}
pub(super) fn cmd_title(&mut self, ctx: &Context) -> R<i32> {
let n = self.player_targets(ctx)?;
let name = self.target_name(Target::Player);
let players = self.player_targets(ctx)?;
let n = players.len();
let name = players
.first()
.map_or_else(Text::empty, |t| self.target_name(*t));
let single = n == 1;
let key = if ctx.has("clear") {
self.titles_or_packet(Titles::clear, C::ClearTitles(play::Bool1 { value: false }));
self.titles_or_packet(
&players,
Titles::clear,
C::ClearTitles(play::Bool1 { value: false }),
);
"cleared"
} else if ctx.has("reset") {
self.titles_or_packet(
&players,
|t| {
t.clear();
t.reset_times();
@@ -172,6 +237,7 @@ impl Game<'_> {
};
let (fade_in, stay, fade_out) = (get("fadeIn"), get("stay"), get("fadeOut"));
self.titles_or_packet(
&players,
|t| t.set_times(fade_in, stay, fade_out),
C::SetTitlesAnimation(play::TitlesAnimation {
fade_in,
@@ -186,13 +252,17 @@ impl Game<'_> {
text: component_nbt(&text.component()),
};
if ctx.has("subtitle") {
self.titles_or_packet(|t| t.set_subtitle(text), C::SetSubtitleText(sent));
self.titles_or_packet(&players, |t| t.set_subtitle(text), C::SetSubtitleText(sent));
"show.subtitle"
} else if ctx.has("actionbar") {
self.titles_or_packet(|t| t.set_overlay(text, false), C::SetActionBarText(sent));
self.titles_or_packet(
&players,
|t| t.set_overlay(text, false),
C::SetActionBarText(sent),
);
"show.actionbar"
} else {
self.titles_or_packet(|t| t.set_title(text), C::SetTitleText(sent));
self.titles_or_packet(&players, |t| t.set_title(text), C::SetTitleText(sent));
"show.title"
}
};
@@ -207,13 +277,27 @@ impl Game<'_> {
self.feedback(message);
Ok(n as i32)
}
/// The player the title is for: the guest acting now is sent the packet
/// (TitleCommand's `connection.send`), the window's own player shows it.
fn titles_or_packet(&mut self, local: impl FnOnce(&mut Titles), packet: C) {
if self.lan_acting.is_some() {
self.guest_packets.push(packet);
} else {
local(&mut self.titles);
/// TitleCommand's `connection.send` for each player: a guest is sent the
/// packet, the window's own player shows it.
fn titles_or_packet(&mut self, players: &[Target], local: impl FnOnce(&mut Titles), packet: C) {
let mut local = Some(local);
for &t in players {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => {
if let Some(local) = local.take() {
local(&mut self.titles);
}
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
}
}
@@ -119,6 +119,7 @@ impl Game<'_> {
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
_ => DamageKind::Generic,
@@ -141,27 +142,37 @@ impl Game<'_> {
Some(Arg::Entity(_)) => self.entities(ctx, "entity", false)?.first().copied(),
_ => None,
};
let cause = match ctx.get("cause") {
Some(Arg::Entity(_)) => self.entities(ctx, "cause", false)?.first().copied(),
_ => None,
};
let from = match ctx.get("location") {
Some(Arg::Coords(_)) => self.position_arg(ctx, "location"),
_ => by.and_then(|t| self.target_position(t)),
};
let hurt = match target {
// The causing mob is the one the hit is credited to and thorns
// answer; the source position gives the knockback its direction.
Target::Player => {
if self.creative || self.spectator() {
// `DamageSource(type, cause, by)`: the death message names the
// causing entity, else the direct one. A mob is noted by
// `hurt_player_from`, a player here.
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
let name = self.target_name(t);
self.note_direct_entity(&ty, name);
}
if self.spectator() {
false
} else if let Some(player) = self.player.as_mut() {
match self.motion.health.damage.id(&ty) {
Some(id) => player
.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false),
None => false,
}
} else {
false
let attacker = match cause.or(by) {
Some(Target::Mob(i)) => Some(Some(i)),
_ => Some(None),
};
self.hurt_player_from(amount, from, &ty, attacker)
}
}
Target::Mob(id) => {
let attacker = by.and_then(|t| match t {
let attacker = cause.or(by).and_then(|t| match t {
Target::Player => Some(Who::Player),
Target::Mob(i) => Some(Who::Mob(i)),
_ => None,
@@ -302,9 +313,18 @@ impl Game<'_> {
float("minVolume", 0.0),
);
let range = if volume > 1.0 { 16.0 * volume } else { 16.0 };
// PlaySoundCommand: one seed for every player.
let seed = self.world.random.next_long();
let here = self.dim.id();
let mut tracker = Tracker::default();
for t in targets {
if t != Target::Player {
if !matches!(t, Target::Player | Target::Guest(_)) {
continue;
}
// Only players in the source's level hear it.
if let Target::Guest(conn) = t
&& self.command_guest(conn).is_none_or(|g| g.dimension != here)
{
continue;
}
let Some(p) = self.target_position(t) else {
@@ -328,8 +348,26 @@ impl Game<'_> {
} else {
(pos, volume)
};
self.audio.play_on(source, &sound, Some(at), v, pitch);
tracker.mark(&holder(Text::literal(self.acting_player_name())));
// The connection is sent the sound; the window's own player
// plays it.
let guest = match t {
Target::Guest(conn) => Some(conn),
_ => self.lan_acting,
};
match guest.and_then(|conn| Some((conn, self.lan.as_ref()?))) {
Some((conn, host)) => {
let packet = host
.hub
.sound_packet(&sound, at, v, pitch, source as i32, seed);
if t == Target::Player {
self.guest_packets.push(packet);
} else {
host.hub.send_to(conn, &packet);
}
}
None => self.audio.play_on(source, &sound, Some(at), v, pitch),
}
tracker.mark(&holder(self.target_name(t)));
}
let name = Text::literal(sound.clone());
let (message, total) = tracker.message(
@@ -606,8 +644,10 @@ impl Game<'_> {
let reached = reached.len() as i32;
if !local {
} else if count <= 0 {
// No count: one particle with the delta as velocity.
self.particles.create(&options, pos, delta);
// handleParticleEvent, count 0: one particle, the speed times
// the delta as velocity (both sent as floats, multiplied as floats).
self.particles
.create(&options, pos, delta.map(|d| f64::from(speed * d as f32)));
} else {
for _ in 0..count {
let r = self.particles.random();
+21
View File
@@ -19,7 +19,17 @@ use std::convert::Infallible;
type ItemConsumable = Consumable<String, ConsumeEffect>;
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
impl Game<'_> {
/// `ServerPlayer.completeUsingItem`: the player's own client is told
/// first, and completes the use itself (eating the food there too).
pub(super) fn send_use_item_complete(&mut self) {
if self.lan_acting.is_some() {
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
}
}
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
pub(super) fn emit_consume(
&mut self,
@@ -57,6 +67,17 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
}
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
if let Some(data) = stack.extra.as_deref() {
for (id, duration) in &data.stew_effects {
if let Some(def) = horizon_client::brewing::effect(id) {
self.add_player_effect(horizon_client::effects::EffectInstance::new(
def.id, *duration, 0,
));
}
}
}
for effect in &consumable.effects {
self.apply_consume_effect(effect);
}
@@ -878,6 +878,9 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -44,13 +44,13 @@ impl Game<'_> {
slot: usize,
stack: Option<ItemStack>,
) {
if let Some(e) = self
.world
.block_entities
.get_mut(&(chest[0], chest[1], chest[2]))
let key = (chest[0], chest[1], chest[2]);
if let Some(e) = self.world.block_entities.get_mut(&key)
&& let Some(s) = e.slots_mut().get_mut(slot)
{
*s = stack;
// TransportItemsBetweenContainers calls Container.setChanged.
self.container_changed(key);
}
}
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
+4 -3
View File
@@ -218,8 +218,9 @@ impl Game<'_> {
);
}
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
/// death and a natural heart drops experience.
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) {
/// death and a natural heart drops experience. `onExplosionHit` does the
/// same for the creaking (`by_player` false), without the experience.
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
@@ -242,7 +243,7 @@ impl Game<'_> {
.state(state)
.and_then(|s| s.properties.get("natural").cloned())
.is_some_and(|v| v == "true");
if natural && !self.creative && !self.spectator() {
if by_player && natural && !self.creative && !self.spectator() {
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
self.spawn_orbs(
[
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -222,7 +222,7 @@ impl Game<'_> {
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.creatures.projectiles.push(arrow);
self.add_projectile(arrow);
}
self.player_projectile_shot();
let pitch = if index == 0 {
+12 -15
View File
@@ -348,11 +348,15 @@ impl Game<'_> {
let Some(state) = self.pack.assets.registry.default_state(&name) else {
return;
};
let at = [
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
];
let p = c.position;
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
// Guest actions temporarily take self.lan; flush once the hub is back.
// Queue before resolving a sprite: a dedicated server has no renderer.
if self.audio.capture.is_some() {
self.lan_cushion_breaks.push((state, particle_at));
}
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
return;
};
@@ -361,14 +365,8 @@ impl Game<'_> {
sprite,
tint,
);
let p = c.position;
self.particles.send_particles(
&options,
[p.x, p.y + 0.666_666_7, p.z],
[0.25, 0.0625, 0.25],
0.05,
10,
);
self.particles
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
}
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
@@ -455,8 +453,7 @@ impl Game<'_> {
glam::DVec3::new(lo.x, lo.y, lo.z),
glam::DVec3::new(hi.x, hi.y, hi.z),
);
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
+165 -2
View File
@@ -13,7 +13,7 @@ use horizon_client::{
save::VERSION_NAME,
text::Text,
};
use horizon_core::geometry::Axis;
use horizon_core::geometry::{Axis, Vec3};
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -766,7 +766,8 @@ impl Game<'_> {
}
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
for m in &self.mobs {
let guests = self.lan.iter().flat_map(|h| h.models.values());
for m in self.mobs.iter().chain(guests) {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
@@ -796,6 +797,159 @@ impl Game<'_> {
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
// unless the type sets one). `at` is the interpolated position.
let at = |previous: Vec3, now: Vec3| {
[
lerp(previous.x, now.x),
lerp(previous.y, now.y),
lerp(previous.z, now.z),
]
};
for p in &self.paintings {
let (min, max) = p.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
}
for f in self.item_frames.iter().filter(|f| !f.invisible) {
let (min, max) = f.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
}
for c in &self.cushions {
let (min, max) = c.bounds();
let p = c.position;
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
}
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
// ArmorStand.getDimensions: a marker has none, a small one is
// half scale with its eyes at 0.9875.
let (size, eye) = match (a.marker, a.small) {
(true, _) => ((0.0, 0.0), 0.0),
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
g.hitbox(
at(b.previous, b.position),
(super::boats::WIDTH as f32, h),
h,
false,
(0.0, b.yaw),
);
}
for c in &self.carts {
let (w, h) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
g.hitbox(
at(c.previous, c.position),
(w, h),
h * 0.85,
false,
(0.0, c.yaw),
);
}
for o in &self.orbs {
g.hitbox(
at(o.previous, o.position),
(0.5, 0.5),
0.425,
false,
(0.0, 0.0),
);
}
for f in &self.falling {
g.hitbox(
at(f.previous, f.position),
(0.98, 0.98),
0.833,
false,
(0.0, 0.0),
);
}
for t in &self.tnts {
g.hitbox(
at(t.previous, t.position),
(0.98, 0.98),
0.15,
false,
(0.0, 0.0),
);
}
for c in &self.crystals {
let p = c.position;
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
}
for p in &self.creatures.projectiles {
use horizon_client::projectiles::ProjectileKind as K;
let (size, eye) = match p.kind {
K::Arrow { .. } | K::Trident => (0.5, 0.13),
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
(0.25, 0.2125)
}
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
}
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
K::LargeFireball { .. } => (1.0, 0.85),
};
g.hitbox(
at(p.previous, p.position),
(size, size),
eye,
false,
(p.pitch, p.yaw),
);
}
for t in &self.tridents {
g.hitbox(
at(t.previous, t.position),
(0.5, 0.5),
0.13,
false,
(t.pitch, t.yaw),
);
}
for r in &self.rockets {
// Projectile.updateRotation: the look follows the motion.
let v = r.velocity;
let yaw = v.x.atan2(v.z).to_degrees() as f32;
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
g.hitbox(
at(r.previous, r.position),
(0.25, 0.25),
0.2125,
false,
(pitch, yaw),
);
}
for h in self
.hook
.iter()
.map(|h| (h.previous, h.position))
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
{
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
for b in &self.bolts {
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
}
// The dragon: 16 by 8, a living entity; its parts follow.
if let Some(d) = &self.dragon {
g.hitbox(
at(d.previous, d.position),
(16.0, 8.0),
6.8,
true,
(0.0, d.yaw),
);
}
// The dragon's parts.
for (id, b) in self.special_targets() {
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
@@ -828,3 +982,12 @@ impl Game<'_> {
g
}
}
/// The (x, y) rotations whose view vector is the step of a direction.
fn step_view(step: [i32; 3]) -> (f32, f32) {
let [x, y, z] = step.map(f64::from);
(
(-y).asin().to_degrees() as f32,
(-x.atan2(z)).to_degrees() as f32,
)
}
+130 -1
View File
@@ -24,6 +24,7 @@ use horizon_client::{
use horizon_protocol::text::translatable;
use horizon_world::nbt::{JavaString, Tag};
use std::{
io::Write,
path::PathBuf,
sync::{Arc, Mutex, OnceLock, mpsc},
time::{Duration, Instant},
@@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) {
}
});
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
println!(
// The launcher can close its stdout pipe while the server keeps running.
let _ = writeln!(
std::io::stdout().lock(),
"[{}] [{thread}/{level}]: {message}",
chrono::Local::now().format("%H:%M:%S")
);
@@ -618,6 +621,22 @@ impl Game<'_> {
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
vec![horizon_protocol::packets::configuration::ServerLink {
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
link: props.bug_report_link.clone(),
}]
} else {
log(
"WARN",
&format!("Failed to parse bug link {}", props.bug_report_link),
);
Vec::new()
},
};
let config = HubConfig {
default_game_mode: game_mode,
@@ -654,6 +673,9 @@ impl Game<'_> {
// DedicatedServer.initServer ends with saveEverything(false, true, true).
self.save_game()?;
self.log_saved_levels();
if let Some(host) = self.lan.as_mut() {
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
}
let mut state = ServerState {
lists,
console,
@@ -1308,3 +1330,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
Some((w, h))
}
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
process::{Command, Stdio},
time::{SystemTime, UNIX_EPOCH},
};
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
const WARNING: &str = "Authentication warning";
const READY: &str = "horizon-console-log-ready\n";
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
fn assert_log_lines(text: &str) {
let expected = [
format!("] [Server thread/INFO]: {LOGIN}"),
format!("] [User Authenticator #1/WARN]: {WARNING}"),
];
assert_eq!(text.lines().count(), expected.len());
for (line, suffix) in text.lines().zip(expected) {
assert!(line.starts_with('['));
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
assert_eq!(&line[9..], suffix);
}
}
#[test]
fn console_logging_survives_closed_stdout() {
if let Some(home) = std::env::var_os(CHILD_HOME) {
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
// Let the parent close the real stdout pipe only after libtest's startup output.
io::stdout().write_all(READY.as_bytes()).unwrap();
io::stdout().flush().unwrap();
io::stdin().read_exact(&mut [0]).unwrap();
std::thread::Builder::new()
.name("Server thread".into())
.spawn(|| {
let captured = capture_log(|| {
log("INFO", LOGIN);
log_from("User Authenticator #1", "WARN", WARNING);
});
assert_eq!(captured, [LOGIN, WARNING]);
horizon_client::logging::flush();
})
.unwrap()
.join()
.unwrap();
// Libtest's final status also writes to stdout; only the game logger is under test.
std::process::exit(0);
}
for closed_stdout in [false, true] {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let home = std::env::temp_dir().join(format!(
"horizon-console-log-test-{}-{unique}",
std::process::id()
));
fs::create_dir(&home).unwrap();
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"game::dedicated::tests::console_logging_survives_closed_stdout",
"--nocapture",
])
.env(CHILD_HOME, &home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
loop {
let mut line = String::new();
assert!(
stdout.read_line(&mut line).unwrap() > 0,
"child was not ready"
);
if line.ends_with(READY) {
break;
}
}
if closed_stdout {
drop(stdout); // No reader remains: the next console write gets EPIPE.
} else {
child.stdout = Some(stdout.into_inner());
}
child.stdin.take().unwrap().write_all(&[1]).unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"closed_stdout={closed_stdout}: {}",
String::from_utf8_lossy(&output.stderr)
);
if !closed_stdout {
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
}
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
fs::remove_dir_all(home).unwrap();
}
}
}
@@ -120,6 +120,11 @@ impl Game<'_> {
p.guest = guest;
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
+2 -2
View File
@@ -982,7 +982,7 @@ impl Game<'_> {
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity);
self.launch_thrown(one, at, shot.velocity, None);
}
"minecraft:firework_rocket" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
@@ -1034,7 +1034,7 @@ impl Game<'_> {
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot(&mut own, dir, power, uncertainty);
self.creatures.projectiles.push(shot);
self.add_projectile(shot);
}
}
}
+186 -22
View File
@@ -27,6 +27,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096;
const CRYSTAL_IDS: u32 = SPECIAL + 16;
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
const MAX_HEALTH: f32 = 200.0;
/// `Mob.getAmbientSoundInterval`.
const AMBIENT_INTERVAL: i32 = 80;
#[derive(Default)]
pub(super) struct Fight {
@@ -48,6 +50,9 @@ pub(super) struct Fight {
respawn: Option<(Respawn, i32)>,
/// The four crystals on the exit portal driving the respawn.
respawn_crystals: Vec<Vec3>,
/// The player behind the crystal blast under way, whose `explosion`
/// damage source names them (`EndCrystal.hurtServer`).
blast_by: Option<FightWho>,
dirty: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -262,6 +267,9 @@ pub(super) struct Dragon {
/// only damage above the last hit lands, and only the excess.
invulnerable: i32,
last_hurt: f32,
/// `lastHurtByPlayer` with its 100 tick memory: the player credited with
/// the kill, and the dragon tick of their last hit.
hurt_by_player: Option<(FightWho, i32)>,
pub death_time: i32,
pub flap: f32,
pub old_flap: f32,
@@ -270,6 +278,8 @@ pub(super) struct Dragon {
flame_count: i32,
sitting_damage: f32,
growl: i32,
/// `Mob.ambientSoundTime`.
ambient_time: i32,
ticks: i32,
/// DragonFlightHistory: (y, yRot) samples, newest at `head`.
history: [(f64, f32); 64],
@@ -291,11 +301,11 @@ pub(super) enum FightWho {
}
/// A player in the End as the dragon sees it.
#[derive(Clone, Copy, Debug)]
struct FightPlayer {
who: FightWho,
position: Vec3,
bounds: Aabb,
alive: bool,
pub(super) struct FightPlayer {
pub(super) who: FightWho,
pub(super) position: Vec3,
pub(super) bounds: Aabb,
pub(super) alive: bool,
creative: bool,
spectator: bool,
}
@@ -385,6 +395,7 @@ impl Dragon {
hurt_time: 0,
invulnerable: 0,
last_hurt: 0.0,
hurt_by_player: None,
death_time: 0,
flap: 0.0,
old_flap: 0.0,
@@ -397,6 +408,7 @@ impl Dragon {
flame_count: 0,
sitting_damage: 0.0,
growl: 100,
ambient_time: 0,
ticks: 0,
history: [(position.y, yaw); 64],
head: 0,
@@ -426,6 +438,10 @@ impl Dragon {
pub fn alive(&self) -> bool {
self.health > 0.0
}
/// `isDeadOrDying` for the killing blow: the dying flight, or no health.
fn down(&self) -> bool {
matches!(self.phase, Phase::Dying { .. }) || !self.alive()
}
fn part_box(&self, i: usize) -> Aabb {
let (w, h) = PART_SIZES[i];
let p = self.parts[i].0;
@@ -693,7 +709,7 @@ impl Game<'_> {
}
/// The players in the level the dragon sees (`level.players()`): the
/// window's own, then every guest in the level.
fn fight_players(&self) -> Vec<FightPlayer> {
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref() {
out.push(FightPlayer {
@@ -722,7 +738,7 @@ impl Game<'_> {
}
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
/// sent to its client).
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
match who {
FightWho::Host => f(self),
FightWho::Guest(conn) => {
@@ -828,8 +844,10 @@ impl Game<'_> {
continue;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter.
self.next_entity += 1;
self.crystals.push(Crystal {
id: 0,
id: self.next_entity,
position: at,
show_bottom: true,
time,
@@ -891,8 +909,12 @@ impl Game<'_> {
.any(|p| p.distance_squared(c.position) < 0.01);
if damage == CrystalDamage::Direct {
let at = [c.position.x, c.position.y, c.position.z];
// The blast's damage source carries the player, so the dragon
// remembers them before the blast can kill it.
self.fight.blast_by = by;
let _ =
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
self.fight.blast_by = None;
}
if respawn_crystal {
self.abort_respawn();
@@ -903,7 +925,7 @@ impl Game<'_> {
if let Some(d) = self.dragon.as_mut() {
if d.nearest_crystal == Some(index) {
d.nearest_crystal = None;
self.hurt_dragon(0, 10.0, true, false);
self.hurt_dragon(0, 10.0, true, by, false);
} else if let Some(n) = d.nearest_crystal.as_mut()
&& *n > index
{
@@ -975,18 +997,46 @@ impl Game<'_> {
self.destroy_crystal(index, by, CrystalDamage::Direct);
Hurt::Landed { full: true }
} else if id >= SPECIAL {
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
let by = by_player.then(|| self.acting_player());
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
} else {
Hurt::Refused
}
}
/// `EnderDragon.hurt(part, source, damage)` on the dragon in the level.
fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt {
/// `by` is the player behind the hit, remembered for the kill credit.
fn hurt_dragon(
&mut self,
part: usize,
damage: f32,
counts: bool,
by: Option<FightWho>,
arrow: bool,
) -> Hurt {
let Some(d) = self.dragon.as_mut() else {
return Hurt::Refused;
};
let was_down = d.down();
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
let hurt = d.hurt(part, damage, counts, arrow);
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
// (100 ticks); a hit the damage cooldown absorbs returns before that.
if counts
&& matches!(hurt, Hurt::Landed { .. })
&& (d.invulnerable != cooldown || d.last_hurt != last_hurt)
&& let Some(who) = by
{
d.hurt_by_player = Some((who, d.ticks));
}
if !was_down && d.down() {
self.credit_dragon_kill();
}
let Some(d) = self.dragon.as_mut() else {
return hurt;
};
if hurt == (Hurt::Landed { full: true }) {
// Mob.playHurtSound: the ambient sound clock starts again.
d.ambient_time = -AMBIENT_INTERVAL;
let at = [d.position.x, d.position.y, d.position.z];
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
@@ -994,6 +1044,47 @@ impl Game<'_> {
}
hurt
}
/// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player
/// who hurt the dragon within the last 100 ticks. A guest's kill is
/// theirs: their statistics, scores and advancements.
fn credit_dragon_kill(&mut self) {
let Some(d) = self.dragon.as_ref() else {
return;
};
let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else {
return;
};
let view = horizon_client::advancements::EntityView {
kind: "minecraft:ender_dragon".to_owned(),
position: [d.position.x, d.position.y, d.position.z],
..Default::default()
};
let id = d.id;
let guest = match who {
FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn),
_ => None,
};
let credited = guest.is_some_and(|conn| {
let Some(mut host) = self.lan.take() else {
return false;
};
let done = self
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view))
.is_some();
self.lan = Some(host);
done
});
if !credited {
self.award_dragon_kill(id, &view);
}
}
/// `ServerPlayer.awardKillScore` for the dragon, on the killer's state.
fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
@@ -1566,6 +1657,7 @@ impl Game<'_> {
age: 0,
duration: 200,
from_dragon: true,
potion: None,
});
}
}
@@ -1658,6 +1750,24 @@ impl Game<'_> {
self.tick_dragon_death();
return;
}
// Mob.baseTick: the ambient sound, rarer right after one.
let roll = d.random.next_int_bound(1000).unwrap_or(999);
let waited = d.ambient_time;
d.ambient_time += 1;
if roll < waited {
d.ambient_time = -AMBIENT_INTERVAL;
let at = [d.position.x, d.position.y, d.position.z];
// LivingEntity.getVoicePitch, from the dragon's own random.
let r = &mut d.random;
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
self.audio.play(
"minecraft:entity.ender_dragon.ambient",
Some(at),
5.0,
pitch,
);
}
let d = self.dragon.as_mut().expect("dragon");
// Flapping, growls and the flap sound.
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
let v = d.velocity;
@@ -2078,12 +2188,18 @@ impl Game<'_> {
age: 0,
duration: 600,
from_dragon: false,
potion: None,
});
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some([next.x, next.y, next.z]),
1.0,
1.0,
// DragonFireball.onHit: level event 2006 (the burst of dragon
// breath and the explosion sound) at its block position.
self.level_event_at(
2006,
[
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
],
1,
);
return false;
}
@@ -2110,6 +2226,10 @@ impl Game<'_> {
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -2127,10 +2247,12 @@ impl Game<'_> {
c.at[1],
c.at[2] + f64::from(a.sin() * d),
];
self.particles.add(
self.particles.add_with(
&ParticleOptions::simple(ParticleType::DragonBreath),
at,
[0.0, 0.0, 0.0],
false,
true,
);
}
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
@@ -2157,11 +2279,14 @@ impl Game<'_> {
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
/// death message names it.
/// death message names it, or the cloud once the dragon is gone.
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
self.note_named_attacker(damage_type, id, name);
} else if damage_type == "minecraft:indirect_magic" {
let name = Text::translate("entity.minecraft.area_effect_cloud", &[]);
self.note_direct_entity(damage_type, name);
}
self.hurt_player_by(damage_type, amount);
}
@@ -2254,8 +2379,22 @@ pub(super) struct Breath {
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
duration: i32,
from_dragon: bool,
pub(super) duration: i32,
pub(super) from_dragon: bool,
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
pub(super) potion: Option<super::potions::PotionCloud>,
}
impl Breath {
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
/// lingering potion's).
pub(super) fn waiting(&self) -> bool {
self.age
< if self.potion.is_some() {
super::potions::LINGERING_WAIT
} else {
20
}
}
}
impl Game<'_> {
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
@@ -2482,6 +2621,8 @@ impl Game<'_> {
layer: 2,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
@@ -2547,6 +2688,8 @@ impl Game<'_> {
layer: 6,
color: c,
light: wrap_light,
glint_uv: [0.0, 0.0],
});
}
(last_sin, last_cos, last_u) = (sin, cos, uu);
@@ -2592,6 +2735,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: 240 | 240 << 8,
glint_uv: [0.0, 0.0],
});
}
}
@@ -2717,8 +2862,9 @@ impl Game<'_> {
let (lo, hi) = (b.min_position(), b.max_position());
self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
});
let by = self.fight.blast_by;
for (part, amount) in hits {
self.hurt_dragon(part, amount, true, false);
self.hurt_dragon(part, amount, true, by, false);
}
}
}
@@ -2832,8 +2978,11 @@ impl Game<'_> {
return false;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter, so
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
self.next_entity += 1;
self.crystals.push(Crystal {
id: 0,
id: self.next_entity,
position: Vec3::new(x + 0.5, y, z + 0.5),
show_bottom: false,
time,
@@ -2850,6 +2999,21 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
/// (`Entity.kill` does not hurt it).
pub(super) fn kill_crystal(&mut self, id: u32) {
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
self.destroy_crystal(i, None, CrystalDamage::Explosion);
}
}
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
/// does not hurt it (the dragon flies its death flight when its health
/// runs out).
pub(super) fn kill_dragon(&mut self, id: u32) {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
self.dragon = None;
}
}
/// Any entity touching `room` (`Level.getEntities(null, box)`).
fn entity_in(&self, room: Aabb) -> bool {
let touches = |lo: Vec3, hi: Vec3| {
@@ -157,8 +157,10 @@ impl Game<'_> {
items: Some(self.res.items()),
..Default::default()
};
// minecraft:post_attack ignite effects of the attacker's weapon (fire aspect).
// minecraft:post_attack effects of the attacker's weapon on the victim:
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
let mut ignite = 0.0f32;
let mut mob_effects = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
@@ -168,24 +170,33 @@ impl Game<'_> {
.into_iter()
.flatten()
{
if effect["enchanted"] == "attacker"
&& effect["affected"] == "victim"
&& effect["effect"]["type"] == "minecraft:ignite"
let e = &effect["effect"];
let kind_of = e["type"].as_str();
if effect["enchanted"] != "attacker"
|| effect["affected"] != "victim"
|| !matches!(
kind_of,
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
)
{
let mut random = self.world.random.clone();
let ok = effect.get("requirements").is_none_or(|r| {
self.res
.stations()
.enchantments
.condition(r, *level, &ctx, &mut random)
});
self.world.random = random;
if ok {
ignite = ignite.max(horizon_client::enchant::level_value(
&effect["effect"]["duration"],
*level,
));
}
continue;
}
let mut random = self.world.random.clone();
let ok = effect.get("requirements").is_none_or(|r| {
self.res
.stations()
.enchantments
.condition(r, *level, &ctx, &mut random)
});
self.world.random = random;
if !ok {
continue;
}
if kind_of == Some("minecraft:ignite") {
ignite =
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
} else {
mob_effects.push((e.clone(), *level));
}
}
}
@@ -195,8 +206,41 @@ impl Game<'_> {
{
m.ignite_for_ticks((ignite * 20.0) as i32);
}
for (effect, level) in mob_effects {
self.apply_mob_effect(mob, &effect, level);
}
self.post_attack_bursts(&held, &kind);
}
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
/// random duration and amplifier between the level-based bounds.
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
use horizon_client::enchant::level_value;
let Some(m) = self.mobs.get_mut(mob) else {
return;
};
let ids: Vec<&str> = match &effect["to_apply"] {
serde_json::Value::String(id) => vec![id.as_str()],
serde_json::Value::Array(list) => list.iter().filter_map(|v| v.as_str()).collect(),
_ => Vec::new(),
};
if ids.is_empty() {
return;
}
// HolderSet.getRandomElement, then Mth.randomBetween for both values.
let pick = m.random.next_int_bound(ids.len() as i32).unwrap_or(0) as usize;
let mut between = |min: &str, max: &str| {
let (lo, hi) = (
level_value(&effect[min], level),
level_value(&effect[max], level),
);
m.random.next_float() * (hi - lo) + lo
};
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
m.add_effect(id, ticks, amplifier);
}
}
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
/// a burst at the player's feet that only knocks back and triggers
/// blocks (`ExplodeEffect` with `block_interaction: trigger`).
@@ -307,8 +351,10 @@ impl Game<'_> {
continue;
}
hits.push(1.0 + self.world.random.next_float() * 4.0);
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
let loss = self.durability_loss(&stack, 2);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += 2;
s.damage += loss;
if self
.res
.items()
+271 -37
View File
@@ -5,6 +5,7 @@
use super::{Dim, Game};
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
environment::Environment,
items::ItemStack,
mesher::Vertex,
@@ -19,7 +20,7 @@ use horizon_core::{
use std::{collections::HashMap, sync::Arc};
/// ServerLevel.END_SPAWN_POINT.
const END_SPAWN: [i32; 3] = [100, 50, 0];
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
const GATEWAY_RADIUS: f64 = 96.0;
const GATEWAY_Y: i32 = 75;
@@ -27,14 +28,64 @@ const GATEWAY_Y: i32 = 75;
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
/// A gateway teleport waiting for its destination chunks.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum GatewayJob {
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
Find { gateway: [i32; 3] },
/// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded.
Find { gateway: [i32; 3], exact: bool },
/// A known exit (exact, or findExitPosition around it).
Known { exit: [i32; 3], exact: bool },
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
gateway: [i32; 3],
entity: Option<&BlockEntity>,
legacy: Option<([i32; 3], bool)>,
) -> Self {
let configured = match entity {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
match configured.or(legacy) {
Some((exit, exact)) => Self::Known { exit, exact },
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
exit: END_SPAWN,
exact: true,
},
None => Self::Find {
gateway,
exact: exact_teleport(entity),
},
}
}
}
/// `TheEndGatewayBlockEntity.exactTeleport` of the block entity at a gateway
/// (false without one).
fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
}
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
/// entity has its own; true when it took the saved one.
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
if entity.exit_portal.is_some() {
return false;
}
entity.exit_portal = Some(exit);
entity.exact_teleport = exact;
true
}
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
if exact { exit } else { find(exit) }
}
#[derive(Default)]
pub(super) struct EndState {
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
@@ -247,16 +298,25 @@ impl Game<'_> {
layer: 2,
color,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
out
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor.
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
if self.dim == Dim::End {
sky.sky_light_factor += self.end.flash.intensity(partial as f32);
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
let intensity = self.end.flash.intensity(partial as f32);
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
intensity / 3.0
} else {
intensity
};
}
}
@@ -599,7 +659,9 @@ impl Game<'_> {
/// EnderDragonFight without a dragon: once the centre of the main island
/// is loaded, place the active exit portal (EndPodiumFeature) and the
/// first gateway (spawnNewGateway) if the portal is not there yet.
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
/// scanState and spawnExitPortal, only while the fight has no exit portal
/// location: a podium that is later broken stays broken.
fn ensure_exit_portal(&mut self) -> Result<()> {
if self.travelling.is_some()
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
@@ -609,8 +671,14 @@ impl Game<'_> {
}
let won = self.fight.previously_killed;
let gateway = self.first_gateway();
if (0..256).any(|y| &*self.block_name([0, y, 0]) == "minecraft:bedrock") {
// The podium exists; the gateway may have been out of range when it was placed.
if self.exit_portal().is_none() {
// findExitPortal remembers the location of a podium that is already there.
if let Some(level) = self.podium_level() {
self.set_exit_portal([0, level, 0]);
}
}
if self.exit_portal().is_some() {
// The gateway may have been out of range when it was placed.
if won
&& self.world.loaded(gateway[0], gateway[2])
&& &*self.block_name(gateway) != "minecraft:end_gateway"
@@ -641,17 +709,40 @@ impl Game<'_> {
}
Ok(())
}
/// The portal level of the podium around the centre column, read from the
/// bedrock that EndPodiumFeature places (rim at the portal level, disc
/// below it, the torch column) so a broken column does not hide it.
fn podium_level(&self) -> Option<i32> {
let mut offsets: Vec<[i32; 3]> = (0..4).map(|dy| [0, dy, 0]).collect();
for z in -4..=4i32 {
for x in -4..=4i32 {
let flat = f64::from(x * x + z * z);
if (6.25..12.25).contains(&flat) {
offsets.push([x, 0, z]);
} else if flat + 1.0 < 6.25 {
offsets.push([x, -1, z]);
}
}
}
(1..253)
.map(|y| {
let score = offsets
.iter()
.filter(|o| &*self.block_name([o[0], y + o[1], o[2]]) == "minecraft:bedrock")
.count();
(score, y)
})
.filter(|&(score, _)| score >= 3)
.max()
.map(|(_, y)| y)
}
/// `spawnExitPortal(true)` after the dragon dies.
pub(super) fn activate_exit_portal(&mut self) -> Result<()> {
let origin = match self.exit_portal() {
Some(o) => o,
None => {
// The rim's level: the top of the bedrock column minus three.
let top = (0..256)
.rev()
.find(|&y| &*self.block_name([0, y, 0]) == "minecraft:bedrock")
.unwrap_or(64);
[0, top - 3, 0]
// The rim's level, found without assuming the centre column is intact.
[0, self.podium_level().unwrap_or(61), 0]
}
};
self.place_podium(origin, true)
@@ -754,6 +845,11 @@ impl Game<'_> {
}
}
}
self.world
.block_entities
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
BlockEntity::EndGateway(Default::default())
});
Ok(())
}
@@ -775,8 +871,7 @@ impl Game<'_> {
self.end.exits.as_mut().expect("loaded exits")
}
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
self.gateway_exits().insert(gateway, (exit, exact));
fn save_gateway_exits(&mut self) -> Result<()> {
let list: Vec<_> = self
.gateway_exits()
.iter()
@@ -788,6 +883,35 @@ impl Game<'_> {
Ok(())
}
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
if self.gateway_exits().remove(&gateway).is_some() {
self.save_gateway_exits()?;
}
Ok(())
}
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
// exit then waits in the side file until the gateway is used again (`gateway_job`).
if self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
{
g.exit_portal = Some(exit);
g.exact_teleport = exact;
self.world.modified.insert(chunk);
return self.forget_gateway_exit(gateway);
}
self.gateway_exits().insert(gateway, (exit, exact));
self.save_gateway_exits()
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
/// travel within the End, waiting for the destination chunks to load.
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
@@ -807,25 +931,39 @@ impl Game<'_> {
/// its exit, or the search for one, and the spot to load before either
/// is resolved ([`Game::resolve_gateway`]).
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
let known = self.gateway_exits().get(&gateway).copied();
let job = match known {
Some((exit, exact)) => GatewayJob::Known { exit, exact },
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => {
GatewayJob::Known {
exit: END_SPAWN,
exact: true,
}
let mut known = self.gateway_exits().get(&gateway).copied();
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
// side file kept for it moves there, and the entry is dropped, so removing or replacing
// the exit (`/data remove block`) is not undone by an old entry.
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
if let Some(saved) = known
&& self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
{
if adopt_saved_exit(g, saved) {
self.world.modified.insert(chunk);
}
None => GatewayJob::Find { gateway },
};
known = None;
let _ = self.forget_gateway_exit(gateway);
}
let job = GatewayJob::for_gateway(
gateway,
self.world
.block_entities
.get(&(gateway[0], gateway[1], gateway[2])),
known,
);
let destination = match job {
GatewayJob::Known { exit, .. } => Vec3::new(
f64::from(exit[0]) + 0.5,
f64::from(exit[1]),
f64::from(exit[2]) + 0.5,
),
GatewayJob::Find { gateway } => {
GatewayJob::Find { gateway, .. } => {
let d = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero()
* 1024.0;
@@ -882,12 +1020,10 @@ impl Game<'_> {
[p[0], p[1] + 1, p[2]]
};
match job {
GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact {
exit
} else {
find_exit(self, exit)
})),
GatewayJob::Find { gateway } => {
GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
// findExitPortalXZPosTentative over the loaded chunks.
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero();
@@ -947,8 +1083,11 @@ impl Game<'_> {
let exit = [tallest[0], tallest[1] + 10, tallest[2]];
self.place_gateway(exit)?;
self.set_gateway_exit(exit, gateway, false)?;
self.set_gateway_exit(gateway, exit, false)?;
Ok(bottom_center(find_exit(self, exit)))
// setExitPosition keeps the gateway's own exactTeleport.
self.set_gateway_exit(gateway, exit, exact)?;
Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
})))
}
}
}
@@ -1240,8 +1379,103 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: full,
glint_uv: [0.0, 0.0],
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::containers::EndGateway;
#[test]
fn a_saved_exit_moves_into_a_gateway_without_one() {
let saved = ([8, 100, 0], true);
let mut bare = EndGateway::default();
assert!(adopt_saved_exit(&mut bare, saved));
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
assert!(bare.exact_teleport);
// The block entity's own exit wins over a stale entry.
let mut own = EndGateway {
exit_portal: Some([1, 2, 3]),
..Default::default()
};
assert!(!adopt_saved_exit(&mut own, saved));
assert_eq!(own.exit_portal, Some([1, 2, 3]));
assert!(!own.exact_teleport);
}
#[test]
fn a_new_exit_keeps_the_gateways_exact_teleport() {
let exact = BlockEntity::EndGateway(EndGateway {
exact_teleport: true,
..Default::default()
});
let plain = BlockEntity::EndGateway(EndGateway::default());
assert!(exact_teleport(Some(&exact)));
assert!(!exact_teleport(Some(&plain)));
assert!(!exact_teleport(None));
assert!(!exact_teleport(Some(&BlockEntity::DaylightDetector)));
let exit = [8, 100, 0];
// Exact: the exit itself, nothing is searched for.
assert_eq!(exit_target(exit, true, |_| panic!("searched")), exit);
assert_eq!(
exit_target(exit, false, |e| [e[0], e[1] + 11, e[2]]),
[8, 111, 0]
);
}
#[test]
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
let gateway = [702, 100, 0];
let exit = [8, 100, 0];
for exact in [true, false] {
let entity = BlockEntity::EndGateway(EndGateway {
exit_portal: Some(exit),
exact_teleport: exact,
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
GatewayJob::Known { exit, exact },
);
}
let empty = BlockEntity::EndGateway(EndGateway::default());
for entity in [None, Some(&empty)] {
assert_eq!(
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
GatewayJob::Known { exit, exact: false }
);
assert_eq!(
GatewayJob::for_gateway(gateway, entity, None),
GatewayJob::Known {
exit: END_SPAWN,
exact: true
}
);
let nearby = [96, 75, 0];
let exact = BlockEntity::EndGateway(EndGateway {
exact_teleport: true,
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(nearby, Some(&exact), None),
GatewayJob::Find {
gateway: nearby,
exact: true
}
);
assert_eq!(
GatewayJob::for_gateway(nearby, entity, None),
GatewayJob::Find {
gateway: nearby,
exact: false
}
);
}
}
}
@@ -60,12 +60,27 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
/// at 20 or more. The render distance is the client setting, capped by
/// the server's view distance when connected
/// (Options.getEffectiveRenderDistance).
pub(super) fn view_scale(&self) -> f64 {
let distance = match &self.remote {
Some(r) => self.settings.render_distance.min(r.view_distance),
None => self.settings.render_distance,
};
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
}
}
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
/// box's mean edge, scaled by the entity distance option.
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
/// box's mean edge, scaled by the view scale.
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
let edge = (size.x + size.y + size.z) / 3.0;
let edge = if edge.is_nan() { 1.0 } else { edge };
edge * 64.0 * f64::from(view_scale)
edge * 64.0 * view_scale
}
impl Game<'_> {
+57 -10
View File
@@ -307,6 +307,12 @@ impl Game<'_> {
)
{
outcome = o;
// dealDefaultKnockback's `indicateDamage` points at the blast
// (the source position), not the no-direction fallback.
if o.knockback && o.blocked <= 0.0 {
p.health
.indicate_damage(center[0] - feet[0], center[2] - feet[2], p.yaw);
}
}
if !spectator && !(creative && flying) {
p.velocity = Vec3::new(
@@ -394,7 +400,14 @@ impl Game<'_> {
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
attacker: exploding.map(horizon_client::mobs::Who::Mob),
// A projectile's owner (`getIndirectSourceEntity`), else
// the exploding mob itself.
attacker: self
.combat
.explosion_owner
.or(exploding)
.filter(|&o| o != m.id)
.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
if hurtable && !m.damage(damage(dist, exposure), source) {
@@ -490,6 +503,15 @@ impl Game<'_> {
);
}
}
/// Runs an explosion whose source empties the hives it destroys
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
/// TNT minecart).
pub(super) fn with_hive_emptying_blast<R>(&mut self, blast: impl FnOnce(&mut Self) -> R) -> R {
self.combat.hive_emptying_blast = true;
let result = blast(self);
self.combat.hive_emptying_blast = false;
result
}
/// `Level.explode` on the server: positions, entities, blocks, fire; then
/// the client's explosion sound.
pub(super) fn server_explode(
@@ -572,16 +594,16 @@ impl Game<'_> {
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
self.audio
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
let block_count = if interaction == BlockInteraction::Keep {
0
} else {
targets.len()
};
// ServerLevel.explode sends toBlow.size() whatever the interaction, and
// `ServerExplosion.isSmall` also covers an explosion that keeps blocks.
let block_count = targets.len();
let small = radius < 2.0 || interaction == BlockInteraction::Keep;
let (world, facts) = (&self.world, self.res.facts());
self.particles.explosion(center, radius, block_count, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, radius, block_count);
self.particles
.explosion(center, small, block_count, radius, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, small, radius, block_count);
Ok(())
}
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
@@ -598,9 +620,30 @@ impl Game<'_> {
}
let name = self.res.facts().get(state).name.clone();
let is_tnt = &*name == "minecraft:tnt";
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
// experience, as Horizon does not track a TNT's owner).
if &*name == "minecraft:creaking_heart" {
self.creaking_heart_broken(pos, false);
}
// Block.onExplosionHit: the drops copy from the block entity, which
// then spills its contents as the block turns to air.
let key = (pos[0], pos[1], pos[2]);
let is_hive = matches!(&*name, "minecraft:beehive" | "minecraft:bee_nest");
// BeehiveBlock.getDrops: the bees of a hive blown up by TNT, a
// creeper, a wither skull, a wither or a TNT minecart come out.
if is_hive && self.combat.hive_emptying_blast {
let facing = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing").cloned());
self.empty_hive(
pos,
horizon_client::beehive::facing_step(facing.as_deref()),
horizon_client::beehive::Release::Emergency,
);
}
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
let custom = self.world.block_names.get(&key).cloned();
self.pending.record(pos, &entity, &name, custom);
@@ -633,6 +676,10 @@ impl Game<'_> {
}
self.pending.clear(pos);
self.level_set(pos, self.world.air)?;
// BeehiveBlock.onExplosionHit: the bees about turn on the player.
if is_hive {
self.anger_nearby_bees(pos);
}
// TntBlock.wasExploded: new PrimedTnt (its push drawn first), then
// getRandomShortFuse(80); no game event.
if is_tnt && self.ui.rules.bool("tnt_explodes") {
+12 -1
View File
@@ -103,12 +103,23 @@ impl Game<'_> {
let b = horizon_core::geometry::Aabb::new(lo, hi);
// Every player in the box, the server's players too.
let pos = f.pos;
// `EvokerFangs.dealDamageTo`: indirectMagic(fangs, owner) while the
// owner lives, plain magic (nobody to name) once it is gone.
let owner = self
.mobs
.iter()
.any(|m| m.id == f.owner && m.alive())
.then_some(f.owner);
let bite = move |g: &mut Self| {
if let Some(p) = g.player_view()
&& p.alive
&& p.bounds.intersects(b)
{
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
let kind = match owner {
Some(_) => "minecraft:indirect_magic",
None => "minecraft:magic",
};
g.mob_hits_player_caused(6.0, pos, kind, owner);
}
};
bite(self);
+58 -40
View File
@@ -23,7 +23,7 @@ pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
pub(super) position: Vec3,
previous: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
life: i32,
lifetime: i32,
@@ -62,11 +62,56 @@ impl Rocket {
item,
}
}
/// One tick of a server's client: the previous position, then `step`.
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
/// One tick of a server's client: the rocket flies on by itself between
/// the server's updates (a rocket that follows an entity is placed by the
/// caller).
pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) {
self.previous = self.position;
if self.attached_to.is_none() {
self.free_flight(scene);
}
self.life += 1;
self.position = step(self.position);
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move. True when a
/// block stopped it.
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 2.0 * h,
self.position.z + h,
),
);
let wanted = if self.angled {
self.velocity
} else {
Vec3::new(
self.velocity.x * 1.15,
self.velocity.y + 0.04,
self.velocity.z * 1.15,
)
};
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
return false;
};
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
self.velocity = if horizontal {
Vec3::new(self.velocity.x, wanted.y, self.velocity.z)
} else {
wanted
};
blocked
}
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
@@ -191,46 +236,14 @@ impl Game<'_> {
r.velocity = p.velocity;
}
} else {
// Not shot at an angle: accelerate (unless blocked sideways) and rise.
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
);
let wanted = if r.angled {
r.velocity
} else {
Vec3::new(
r.velocity.x * 1.15,
r.velocity.y + 0.04,
r.velocity.z * 1.15,
)
};
if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) {
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
if r.angled && blocked {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
let horizontal =
(moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
r.position = Vec3::new(
r.position.x + moved.x,
r.position.y + moved.y,
r.position.z + moved.z,
);
r.velocity = if horizontal {
Vec3::new(r.velocity.x, wanted.y, r.velocity.z)
} else {
wanted
};
if r.free_flight(&mut scene) && r.angled {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
}
let at = [r.position.x, r.position.y, r.position.z];
@@ -383,7 +396,12 @@ impl Game<'_> {
/// lit at the rocket's light probe.
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let orientation = glam::Mat4::from_quat(self.camera_orientation());
for r in self.rockets.iter().filter(|r| !r.attached) {
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
for r in self
.rockets
.iter()
.filter(|r| !r.attached && r.attached_to.is_none())
{
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
// EntityRenderer.shouldRender: its 0.25 box in the view.
if !self.standing_entity_in_view(
+31 -6
View File
@@ -2,6 +2,7 @@
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use horizon_client::advancements::EntityView;
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
blocks::Fluid,
@@ -30,8 +31,8 @@ enum State {
pub(super) struct Hook {
/// Its entity id, which a server's players track it under.
id: u32,
position: Vec3,
previous: Vec3,
pub(super) position: Vec3,
pub(super) previous: Vec3,
velocity: Vec3,
state: State,
life: i32,
@@ -77,7 +78,7 @@ impl Game<'_> {
let at = [p.position.x, p.position.y, p.position.z];
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
if self.hook.is_some() {
let damage = self.retrieve_hook();
let damage = self.retrieve_hook(stack);
self.damage_held(damage);
self.audio.play(
"minecraft:entity.fishing_bobber.retrieve",
@@ -143,7 +144,7 @@ impl Game<'_> {
true
}
/// `FishingHook.retrieve`: the rod damage it costs.
fn retrieve_hook(&mut self) -> u32 {
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
let Some(hook) = self.hook.take() else {
return 0;
};
@@ -167,6 +168,15 @@ impl Game<'_> {
item.velocity = pulled(item.velocity);
hooked_item = true;
}
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
let hooked = self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| self.mob_entity(m));
if let Some(hooked) = hooked {
self.trigger_fishing_rod_hooked(rod, &hooked, &[]);
}
// `hookedIn instanceof ItemEntity ? 3 : 5`.
damage = if hooked_item { 3 } else { 5 };
} else if hook.nibble > 0 {
@@ -191,6 +201,7 @@ impl Game<'_> {
Some(hook.open_water),
);
self.world.random = random;
let mut caught = Vec::new();
for stack in items {
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
if self
@@ -222,6 +233,7 @@ impl Game<'_> {
if let Some(potion) = &stack.potion {
item = item.with_potion(potion);
}
caught.push(item.clone());
let (dx, dy, dz) = (
owner.x - hook.position.x,
owner.y - hook.position.y,
@@ -241,6 +253,15 @@ impl Game<'_> {
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
}
// CriteriaTriggers.FISHING_ROD_HOOKED: the hook itself is the entity.
let bobber = EntityView {
kind: "minecraft:fishing_bobber".into(),
position: [hook.position.x, hook.position.y, hook.position.z],
on_ground: hook.on_ground,
fishing_open_water: Some(hook.open_water),
..Default::default()
};
self.trigger_fishing_rod_hooked(rod, &bobber, &caught);
damage = 1;
}
if hook.on_ground {
@@ -433,6 +454,8 @@ impl Game<'_> {
let options = ParticleOptions::simple(kind);
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(&options, at, spread, speed, count);
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
@@ -643,6 +666,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
@@ -770,8 +795,8 @@ impl Game<'_> {
pub(super) struct RemoteHook {
id: u32,
owner: u32,
position: Vec3,
previous: Vec3,
pub(super) position: Vec3,
pub(super) previous: Vec3,
}
impl Hook {
+1 -1
View File
@@ -114,7 +114,7 @@ impl Game<'_> {
living: Some(HurtBob {
hurt_time: p.health.hurt_animation_time as f32 - partial,
hurt_duration: hurt.hurt_duration,
hurt_dir: 0.0,
hurt_dir: p.health.hurt_dir,
death_time: self.death_time().map(|t| t as f32 + partial),
}),
player: Some((
+6 -3
View File
@@ -311,8 +311,9 @@ impl Game<'_> {
let sprites = &self.gui.atlas.sprites;
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
// of 1.
let range = 16.0 * 64.0 * self.view_scale();
for f in &self.item_frames {
let entity_start = out.len();
let map = f.item.as_ref().and_then(super::maps::map_id);
@@ -377,6 +378,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
};
@@ -456,7 +459,7 @@ impl Game<'_> {
}
for (id, m, light) in maps {
let entity_start = out.len();
self.map_vertices(id, m, light, out);
self.map_vertices(id, true, m, light, out);
self.cull_entity_vertices(out, entity_start);
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Glowing entities (`EntityRenderer.extractRenderState`'s outlineColor): which entities
//! appear glowing, in which team colour, and their silhouettes for the renderer's entity
//! outline (`OutlineBufferSource`).
use super::{Game, Vertex};
use horizon_client::{mobs::Mob, scoreboard as sb};
/// `Entity.getTeamColor`: the team's colour, else white.
pub(super) fn team_color(team: Option<&sb::Team>) -> u32 {
team.and_then(|t| t.color.as_deref())
.and_then(sb::team_color_rgb)
.unwrap_or(0xFF_FFFF)
}
impl Game<'_> {
/// `Entity.isCurrentlyGlowing` of an entity that is not living (an item, a projectile):
/// its `Glowing` tag, or shared flag 6 as a server sent it.
pub(super) fn entity_glowing(&self, id: u32) -> bool {
self.remote_glowing(id).unwrap_or_else(|| {
self.persist_raw(id)
.and_then(|t| horizon_client::save::number(t.get("Glowing")))
.is_some_and(|b| b != 0)
})
}
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
/// colour (`getTeamColor`; another player's team goes by name).
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
if !m.is_currently_glowing() {
return None;
}
let team = if &*m.kind.id == "minecraft:player" {
let names = self.remote_player_names();
let name = names.into_iter().find(|(id, _)| *id == m.id);
name.and_then(|(_, name)| self.score.board.players_team(&name))
} else {
self.mob_team(m.id)
};
Some(team_color(team))
}
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
/// alpha kept (`OutlineBufferSource`'s team-coloured copy of each draw).
pub(super) fn mark_outline(&self, vertices: &[Vertex], rgb: u32) {
let color = horizon_client::mobs::render::abgr(rgb);
self.glowing_vertices
.borrow_mut()
.extend(vertices.iter().map(|v| Vertex {
color,
normal: [0; 4],
..*v
}));
}
}
+59 -1
View File
@@ -3,7 +3,7 @@
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
//! (`LivingEntity.SwingState`) and the first-person equip heights
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
use super::{Game, Mining, Screen};
use super::{Game, Mining, Screen, minecarts::CartKind};
use horizon_client::{
Result,
attack_strength::HeldStack,
@@ -289,6 +289,64 @@ impl Game<'_> {
// Only alive mobs are hit, so a picked living entity is alive.
Some(true)
}
/// `Hud.canRenderCrosshairForSpectator` for the nearest hit in reach: an
/// entity that is a `MenuProvider` (a chest or hopper minecart, a chest
/// boat), or a block whose `showAsInteractableInSpectatorMode` holds.
pub(super) fn spectator_crosshair(&self) -> bool {
let cart = self.cart_hit().map(|(i, d)| {
let kind = &self.carts[i].kind;
(d, matches!(kind, CartKind::Chest | CartKind::Hopper))
});
let boat = self.boat_hit().map(|(i, d)| (d, self.boats[i].chest));
let others = [
self.mob_hit().map(|h| h.1),
self.armor_stand_hit().map(|h| h.1),
self.cushion_hit().map(|h| h.1),
self.painting_hit().map(|h| h.1),
self.frame_hit().map(|h| h.1),
self.dragon_hit().map(|h| h.1),
];
let nearest = cart
.into_iter()
.chain(boat)
.chain(others.into_iter().flatten().map(|d| (d, false)))
.min_by(|a, b| a.0.total_cmp(&b.0));
if let Some((distance, menu)) = nearest
&& self.before_block(distance)
{
return menu;
}
let Some(hit) = self.pick() else {
return false;
};
let name = &*self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name;
let name = name.trim_start_matches("minecraft:");
match name {
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
| "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table"
| "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper"
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
| "end_portal" | "end_gateway" => true,
// ChestBlock.getMenuProvider: none while a redstone conductor
// sits on top of either half (a sitting cat aside).
"chest" | "trapped_chest" => {
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
let blocked = |p: (i32, i32, i32)| {
let above = self.world.block(p.0, p.1 + 1, p.2);
self.res.behaviours().facts(above).flags
& horizon_world::level::data::state_flags::CONDUCTOR
!= 0
};
!blocked(key) && self.chest_other_half(key).is_none_or(|o| !blocked(o))
}
_ => name.ends_with("shulker_box"),
}
}
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
pub(super) fn main_hand_attack_speed(&self) -> f64 {
horizon_client::attack_strength::BASE_ATTACK_SPEED
@@ -20,7 +20,8 @@ use horizon_core::{
};
use horizon_world::{
block_collision::BlockCollisionRegistry, collision_context::CollisionContext,
heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates,
heightmap::HeightmapType, level::flags::UPDATE_CLIENTS, registry::BlockStateId,
worldgen::terrain::TerrainBlockStates,
};
/// The loaded world as the spawn searches query it.
@@ -121,6 +122,96 @@ impl Game<'_> {
}
Ok(Some([ox * 16 + 8, height, oz * 16 + 8]))
}
/// MinecraftServer.setInitialSpawn's bonus chest: BonusChestFeature.place
/// in the spawn chunk, a chest with the spawn bonus loot and a torch on
/// each side that can hold one, on the first free column of a shuffled
/// walk over the chunk. Skipped while the chunk is not loaded.
pub(super) fn place_bonus_chest(&mut self, origin: [i32; 3]) -> Result<()> {
use horizon_client::containers::BlockEntity;
let (cx, cz) = (origin[0] >> 4, origin[2] >> 4);
let registry = &self.pack.assets.registry;
let (Some(chest), Some(torch)) = (
registry.default_state("minecraft:chest"),
registry.default_state("minecraft:torch"),
) else {
return Ok(());
};
if !self.streaming.is_lit(cx, cz) {
return Ok(());
}
// `level.getRandom()`. Util.toShuffledList of the chunk's x and z.
let random = &mut self.world.random;
let mut shuffled = |min: i32| -> [i32; 16] {
let mut values: [i32; 16] = std::array::from_fn(|i| min + i as i32);
for end in (2..=16usize).rev() {
let other = random.next_int_bound(end as i32).unwrap_or(0) as usize;
values.swap(end - 1, other);
}
values
};
let (xs, zs) = (shuffled(cx * 16), shuffled(cz * 16));
for x in xs {
for z in zs {
let y = self
.spawn_columns()
.height(HeightmapType::MotionBlockingNoLeaves, x, z);
if self.world.block(x, y, z) != self.world.air && !self.collision_empty([x, y, z]) {
continue;
}
self.set_block_clients([x, y, z], chest)?;
let seed = self.world.random.next_long();
self.world
.block_entities
.insert((x, y, z), BlockEntity::Chest(Vec::new()));
self.load_block_entity_nbt(
[x, y, z],
&format!(
"{{LootTable:\"minecraft:chests/spawn_bonus_chest\",LootTableSeed:{seed}L}}"
),
);
for [dx, dz] in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let at = [x + dx, y, z + dz];
let survives = self.with_level(|b, level, ticks| {
b.can_survive(level, ticks, BlockPos::new(at[0], at[1], at[2]), torch)
});
if survives {
self.set_block_clients(at, torch)?;
}
}
return Ok(());
}
}
Ok(())
}
/// `Level.setBlock(pos, state, UPDATE_CLIENTS)`: no neighbour updates.
fn set_block_clients(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
self.with_level(|b, level, ticks| {
b.set_block(
level,
ticks,
BlockPos::new(pos[0], pos[1], pos[2]),
state,
UPDATE_CLIENTS,
);
});
self.process_level_events()
}
/// The block at `pos` has no collision shape.
fn collision_empty(&self, pos: [i32; 3]) -> bool {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
self.collision
.collision_shape_with_context(
&mut scene,
self.world.block(pos[0], pos[1], pos[2]),
BlockPos::new(pos[0], pos[1], pos[2]),
&CollisionContext::empty(false),
)
.is_ok_and(|shape| shape.is_empty())
}
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
/// player who has no position yet. None while a candidate's chunk loads.
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
@@ -52,6 +52,8 @@ struct Flags {
/// server follows while it runs (IntegratedServer.tickServer).
render_distance: AtomicI32,
simulation_distance: AtomicI32,
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
saving: AtomicBool,
}
/// Work the window hands its server thread (`MinecraftServer.execute`).
@@ -69,6 +71,10 @@ impl Integrated {
pub fn smoothed_tick_ms(&self) -> f32 {
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
}
/// `MinecraftServer.isCurrentlySaving`.
pub fn is_saving(&self) -> bool {
self.flags.saving.load(Ordering::Relaxed)
}
/// `IntegratedServer.tickServer` skips the world while the game is paused.
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
@@ -148,6 +154,12 @@ pub(super) struct Serving {
/// is out of its place: applied after the tick.
pending: Option<Publish>,
}
impl Serving {
/// The window reads this as `MinecraftServer.isCurrentlySaving`.
pub(super) fn set_saving(&self, saving: bool) {
self.flags.saving.store(saving, Ordering::Relaxed);
}
}
enum Publish {
Open(TcpListener, LanSettings),
Close,
@@ -408,6 +420,8 @@ impl Game<'_> {
s.flags.published.store(0, Ordering::Relaxed);
}
if let Some(mut host) = self.lan.take() {
// PlayerList.removeAll: the owner and every guest leave the game.
host.award_leave_game_to_all();
for g in &host.hub.guests {
self.save_guest(&host, g);
}
+42 -9
View File
@@ -129,6 +129,7 @@ pub(super) struct Host<'a> {
banners: &'a horizon_client::banner::Banners,
sprites: &'a HashMap<String, Rect>,
skins: &'a RefCell<horizon_client::skins::Downloader>,
profiles: &'a RefCell<horizon_client::skins::Resolver>,
}
impl SpecialHost for Host<'_> {
fn banner_texture(&self, pattern: &str) -> Option<String> {
@@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> {
fn player_skin(&self, profile: &Value) -> Option<String> {
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
// accepts a texture property without a signature, unlike the tab list.
let url = horizon_client::skins::profile_skin_url(profile)?;
let location =
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
let _ = self
.skins
.borrow_mut()
.state(horizon_client::skins::Kind::Skin, &url);
self.sprites.contains_key(&location).then_some(location)
// A `Dynamic` profile (a name or id only) resolves through the
// services (`PlayerSkinRenderCache.getOrDefault`).
Some(horizon_client::skins::head_skin(
profile,
&mut self.profiles.borrow_mut(),
&mut self.skins.borrow_mut(),
|location| self.sprites.contains_key(location),
))
}
}
@@ -194,6 +195,7 @@ impl Game<'_> {
banners: &self.res.stations().banners,
sprites: &self.gui.atlas.sprites,
skins: &self.skin_downloads,
profiles: &self.skin_profiles,
}
}
/// The client level as item models read it: worked out once a frame (a
@@ -296,8 +298,32 @@ impl Game<'_> {
x,
y,
self.started.elapsed().as_secs_f32(),
self.settings.file.double("glintSpeed") as f32,
self.settings.file.double("glintStrength") as f32,
);
}
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
pub(super) fn glint(&self) -> Option<item_render::Glint> {
self.gui
.atlas
.sprites
.get("misc/enchanted_glint_item")
.copied()
.map(|_| item_render::Glint {
speed: self.settings.file.double("glintSpeed") as f32,
alpha: self.settings.file.double("glintStrength") as f32,
millis: self.started.elapsed().as_secs_f32() * 1000.0,
scale: 0.5,
})
}
/// The glint scroll phase shared by world items and armour
/// (`item_render::Glint::scroll_ticks`).
pub(super) fn glint_ticks(&self) -> f32 {
item_render::Glint::scroll_ticks(
self.started.elapsed().as_secs_f32() * 1000.0,
self.settings.file.double("glintSpeed") as f32,
)
}
/// Submit a resolved item under `pose` (camera-relative) at `light`
/// (packed lightmap coordinates).
pub(super) fn submit_item_world(
@@ -311,6 +337,7 @@ impl Game<'_> {
out,
light,
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
glint: self.glint(),
};
state.submit(
&self.pack.item_renderer,
@@ -332,6 +359,7 @@ impl Game<'_> {
out,
light,
lights: None,
glint: self.glint(),
};
state.submit(
&self.pack.item_renderer,
@@ -405,7 +433,12 @@ impl Game<'_> {
) {
self.submit_item_world(&state, copy, vertex_light(packed), out);
}
self.cull_entity_vertices(out, entity_start);
if self.entity_glowing(item.id) {
let rgb = super::glowing::team_color(self.mob_team(item.id));
self.mark_outline(&out[entity_start..], rgb);
} else {
self.cull_entity_vertices(out, entity_start);
}
}
}
}
@@ -79,6 +79,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.stats.custom("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
@@ -90,6 +91,8 @@ impl Game<'_> {
self.set_has_record(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff. Show more