Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5bb7be3765 Merge main into feat/lua-mods (Cargo.toml members one per line; stability.md server-mods row from main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:04:24 +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 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 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 Marie 8ecf5d6e47 Merge remote-tracking branch 'origin/main' into perf/parity-solo-window-harnesses 2026-10-27 07:03:34 +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 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 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 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
Ludovic Marie c79b5be2e4 Merge #2564
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:40:59 +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 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 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 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 ab099b2701 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:28:26 +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
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
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
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
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 Marie a9dcea59e7 Merge pull request #1901 from ludovic111/feat/server-mods-commands
🤖 Agent: the parity stop at c5534f0d7 was friends_integration, which main broke through #2501 and #2598 fixes, plus remote_fireworks and server_player_sculk (load on the old runner; both pass on main on the Mac mini). None of the three is from this PR. Merged main (fde2dfffe, a clean merge); pr_ready --quick passed: 1357 tests, clippy, launcher.
2026-10-27 03:56:44 +01:00
Ludovic Marie 4ba8105ecc Merge pull request #2104 from rdbtCVS/fix/1482-evoker-fangs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the server sends entity event 4 once its warmup ends (`EvokerFangs.tick`), `handleEntityEvent(4)` starts the client's attack and plays the sound locally, and the client only counts `lifeTicks` down for the animation, while the server deals the damage and discards. Moving the host's sound to `play_unshared` matches that, since each client plays its own. It conflicted with #2094 (both hook entity events) once that merged, so I merged main into it, keeping both calls. `lan_e2e.py --check fangs` passes in both modes. It conflicted again with main in `tools/lan_e2e.py` (new checks on both sides), so I merged main in once more, keeping every check; clippy is clean. Fixes #1482. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-27 03:56:41 +01:00
Ludovic Marie 226b42a046 Merge pull request #2449 from RoyalPixelsDE/fix/end-portal-without-players
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `EnderDragonFight.tick` scans the state and places the exit portal only when `dragonEvent.getPlayers()` is not empty, so a force-loaded End with nobody in it gets no portal, as here; the new `forced_end` harness compares both servers. 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 @FusselTV!
2026-10-27 03:54:13 +01:00
Ludovic Marie 12381ec668 Merge pull request #2439 from RoyalPixelsDE/fix/generated-block-entities
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WorldGenRegion.setBlock` gives a block with a block entity a `DUMMY` entry that becomes `newBlockEntity`'s default, so the deep dark's sensors, catalysts and shriekers and potent sulfur get theirs, while placed data is kept. 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 @FusselTV!
2026-10-27 03:54:10 +01:00
Ludovic Marie 8376200697 Merge pull request #2438 from RoyalPixelsDE/fix/end-crystals-at-generation
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SpikeFeature` places the end crystals during decoration, so they are saved with the chunk that generates them, 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 @FusselTV!
2026-10-27 03:54:07 +01:00
Ludovic Marie 2dd5792ae3 Merge pull request #2436 from RoyalPixelsDE/fix/brushable-fall
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `FallingBlockEntity` with a `BrushableBlock` breaks it where it lands (`onBrokenAfterFall`'s sound and level event). Left for later: the game event uses the block position, not the bounding-box centre. 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 @FusselTV!
2026-10-27 03:54:03 +01:00
Ludovic Marie 94d063684c Merge pull request #2434 from RoyalPixelsDE/fix/forced-decoration-order
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ForceLoadCommand` adds its FORCED tickets x-outer, z-inner, all at once, so the decoration order follows from that; the new `forced_order` reproduces it with the same ticket simulation as `vanilla_order`. 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 @FusselTV!
2026-10-27 03:54:00 +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 Marie 9d8ecc533d Merge pull request #2450 from RoyalPixelsDE/fix/structure-starts-split
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: a chunk's `STRUCTURE_STARTS` are its `getAllStarts`, whether or not a piece reaches it; `starts_at` now gives those, as vanilla saves them. 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 @FusselTV!
2026-10-27 03:51:42 +01:00
Ludovic Marie e2428e8735 Merge pull request #2437 from RoyalPixelsDE/fix/structure-mob-finalize
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `StructureTemplate.placeEntities` and the pieces' markers call `finalizeSpawn(level, difficulty, STRUCTURE, null)` inside the `WorldGenRegion` with its random, and the save keys now follow `addAdditionalSaveData`. Your rebased head is the reviewed change plus the comment cleanup, on current main. 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 @FusselTV!
2026-10-27 03:49:24 +01:00
Ludovic Marie bad8c9eba4 Merge pull request #2474 from kacpherek/fix/unavailable-options
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: these three options have no system in Horizon yet, so they are drawn inactive with the same notice the other unavailable options use, and clicks do 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 03:48:41 +01:00
Ludovic Marie 70cba407b5 Merge pull request #2472 from kacpherek/fix/music-frequency
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `MusicManager.MusicFrequency` and the option's listener (`setMinutesBetweenSongs`). I pushed one fix: in `SoundOptionsScreen` Music Frequency starts its own row (beside Music Toast), not beside Show Subtitles. 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:48:38 +01:00
Ludovic Marie 291239c90c Merge pull request #2470 from kacpherek/fix/execute-eye-height-positioned-as
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `EntityAnchorArgument` and `execute positioned as` (it keeps the anchor). I pushed one fix: a small armor stand's eyes are at 0.9875 (`BABY_DIMENSIONS.withEyeHeight`), not half the type's, and the types' eye heights are floats widened as `getEyeY` does. 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:48:36 +01:00
Ludovic Marie dfd0aa0266 Merge pull request #2469 from kacpherek/fix/high-contrast-pack
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the High Contrast option adds or removes the `high_contrast` pack (`Options.updateResourcePacks`), and your follow-up keeps the pack screen's selection in step. 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:48:32 +01:00
Ludovic Marie f6e156a4ef Merge pull request #2468 from kacpherek/fix/left-handed-swing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `HumanoidModel.setupAttackAnimation` and `AnimationUtils.swingWeaponDown` use the main arm of a left-handed mob, and `PiglinModel`'s crossbow and admiring poses mirror with it. 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:48:29 +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 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 Marie e91678b994 Merge pull request #2589 from ludovic111/fix/remote-fireworks-slow-screens
🤖 Agent: remote_fireworks passes 3 of 3 on playhorizon (frames drawn on the CPU) with the earlier shots and the newly-red count.
2026-10-27 03:26:40 +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 Marie 9e359f230d Merge pull request #2599 from ludovic111/feat/launcher-screenshots
🤖 Agent: launcher-features-1 — item 6 (screenshots gallery); base retargeted to main after #2597 merged, merges cleanly, CI green.
2026-10-27 03:17:15 +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
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
Ludovic Marie 783577d2de Merge pull request #2597 from ludovic111/feat/launcher-play-time
🤖 Agent: launcher-features-1 — item 5 (play time per instance); base retargeted to main after #2596 merged, merges cleanly, CI green.
2026-10-27 02:55:07 +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 Marie 8458872d07 Merge pull request #2596 from ludovic111/feat/launcher-quick-play
🤖 Agent: play into a world / join a server + servers.dat list (item 4 of launcher-features-1), on main after #2593: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally (servers.dat round trip keeps every field and hidden entries); screenshots in the PR.
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 Marie cd520ce61d Merge pull request #2593 from ludovic111/feat/launcher-worlds
🤖 Agent: worlds in the launcher (item 3 of launcher-features-1), on main after #2591: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally (vanilla-style backup layout, copy naming); screenshots in the PR.
2026-10-27 02:48:44 +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 bad95e4f6a horizon-mod-lua: string.rep and table.concat keep bytes that are not UTF-8
Lua strings are byte strings. Both converted their arguments with
to_str_lossy, so a byte such as \255 (binary values a mod keeps in
horizon.storage, for one) came back as U+FFFD's three bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:52 +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 Marie f12e457a25 Merge pull request #2591 from ludovic111/feat/launcher-instance-version
🤖 Agent: version per instance (item 2 of launcher-features-1), now on main after #2590: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally (pin/unpin test); screenshots in the PR.
2026-10-27 02:28:43 +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 Marie b03d1411d1 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-27 02:24:19 +01:00
Ludovic Marie 55cc84718a Merge remote-tracking branch 'origin/main' into feat/server-mods-commands 2026-10-27 02:24:14 +01:00
Ludovic Marie fde2dfffef Merge pull request #2598 from ludovic111/fix/friends-guest-spot
🤖 Agent: fixes parity-main's friends_integration (red since 4be5c8eb5). Cause: #2501 spawns new players at a random spot near the spawn, as vanilla does, and test_friends.py assumed the guest stood on the spawn itself. Main failed 4/4 on the Mac mini, main with #2501 reverted passed 2/2, and this branch passed 4/4 on the server thread and 1/1 in-frame. Python only; harness-only, skip-release.
2026-10-27 02:14:59 +01:00
Ludovic Marie 155f52af58 Merge pull request #2590 from ludovic111/feat/launcher-instances-ui
🤖 Agent: launcher instances in the window (item 1 UI of launcher-features-1), now on main after #2587: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally; screenshots in the PR.
2026-10-27 02:11:48 +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
Ludovic Marie fd6fdcbf75 Merge pull request #2587 from ludovic111/feat/launcher-instances
🤖 Agent: launcher instances core (item 1 of launcher-features-1). Verify and the three-platform launcher build passed; fmt, clippy -D warnings and all launcher tests pass locally, including the migration test (no existing file moves); CLI and play checked in scratch homes.
2026-10-27 02:10:10 +01:00
Ludovic MarieandClaude Opus 5.5 0d5ed8122e Launcher: screenshots gallery per instance
Home shows the selected instance's newest screenshots (what F2 saves in its
screenshots/ folder), all of them on request, and opens one over the window
with Newer/Older (and the arrow keys), Copy (the picture to the clipboard),
Open, Show in folder and Delete (to the trash, after a confirmation).

CLI: `screenshots [list|delete] [--instance]`; `screenshots delete` needs
confirm over MCP. worlds::to_trash is shared by worlds and screenshots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:09:39 +01:00
Ludovic Marie fd1d6a2762 Merge pull request #2595 from ludovic111/feat/game-quick-play
🤖 Agent: game quick-play flags (item 4 of launcher-features-1): pr_ready --quick passed (1281 horizon-client tests, clippy, fmt); verify passed and CI's parity scope selected no harness for this argument-parsing change; checked by hand with the real game (missing world → title screen, existing world opens).
2026-10-27 02:03:26 +01:00
Ludovic Marie 4fe8f71530 Merge remote-tracking branch 'origin/main' into feat/server-mods-commands 2026-10-27 02:03:13 +01:00
Ludovic MarieandClaude Opus 5.5 ea0cc65aea Launcher: play time per instance
Every run of the game started from the window or `horizon-launcher play`
adds its length to its instance's play time in instances.json (by id, so a
renamed instance keeps it), with when it ended. Home's Instance row shows
"3 h 06 min in total · last played today"; `instance list` shows it too,
and its JSON gives played_secs and last_played_ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:02:15 +01:00
Ludovic Marie a0ef14f1e2 Merge pull request #2582 from ludovic111/perf/parity-one-client-profile
🤖 Agent: harnesses build horizon-client in one profile; 6 harnesses checked fresh on playhorizon and the Mac mini.
2026-10-27 01:58:38 +01:00
Ludovic MarieandClaude Opus 5.5 4b761abcb2 Launcher: play straight into a world or server, and the Multiplayer list
Each world on Home gets Play (the game starts with --world <folder>), and
Home lists the selected instance's servers from the game's own servers.dat:
Join starts the game with --server <address>, Add server and Remove change
the file. server_list reads and writes it as vanilla's ServerList does
(uncompressed NBT, a new file then servers.dat_old) and writes every entry
back with all its fields, the hidden Direct Connection ones too.

CLI: `multiplayer [list|add|remove] [--instance]`, `play --server`;
MCP: play takes `server`; `multiplayer remove` needs confirm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:56:41 +01:00
Ludovic Marie 0a75323caa Merge pull request #2584 from ludovic111/perf/parity-manifest-aware
🤖 Agent: crate splits' workspace-only manifest changes no longer select every harness (354 to 209 on #2160 and #2215); other PRs select as before.
2026-10-27 01:49:27 +01:00
Ludovic Marie 4f5ed6a19b Merge pull request #2588 from ludovic111/chore/workspace-members-per-line
🤖 Agent: merging. Formatting only: Cargo.toml's workspace members one per line (38, including #2160's), so new crates stop conflicting. tomllib parses it identical to main and Cargo.lock is unchanged, so no parity run is needed; verify and launcher pass.
2026-10-27 01:45:25 +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
Ludovic MarieandClaude Opus 5.5 d12dc5d1e1 Game: vanilla's --quickPlaySingleplayer and --quickPlayMultiplayer
The launcher starts the game straight into a world or a server. Vanilla's
names for that (client Main, QuickPlay) now work too: --quickPlayMultiplayer
is --server; --quickPlaySingleplayer opens a world of saves/ only if it
exists, as QuickPlay.joinSingleplayerWorld does, and shows the title screen
otherwise (--world also creates one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:43:25 +01:00
Ludovic MarieandClaude Opus 5.5 c70e2fae74 Launcher: worlds of an instance, with backup, copy and delete
Home lists the selected instance's worlds from their level.dat (name, mode,
version, last played) with their size and picture. Back up zips one into
backups/ as vanilla's Make Backup does; Copy makes "<name> (1)" with that
name in its level.dat; Delete moves it to the system's trash after a
confirmation (the file manager call on macOS, no Finder permission);
Open folder. `horizon-launcher worlds list|backup|copy|delete`, and their
MCP tools (delete needs confirm). level.dat is read with horizon-world's NBT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:37:31 +01:00
Ludovic MarieandClaude Opus 5.5 64f9ff4529 Merge origin/main into chore/workspace-members-per-line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:37:20 +01:00
Ludovic Marie f5b7bdba1d Merge pull request #2160 from ludovic111/refactor/horizon-command
🤖 Agent: merging the first crate split (horizon-command + horizon-client-core, pure move, re-exported under the old paths). CI parity passed at 17f8419ac (head merged into main), verify and launcher pass, approved by rdbt's review agent; landed by hand while the REST pool refills.
2026-10-27 01:36:58 +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
Ludovic MarieandClaude Opus 5.5 90f2755245 Launcher: a Horizon version per instance
An instance plays the launcher's version, which follows updates, unless it is
kept on another installed one: the Play page's picker pins the selected
instance (picking the version marked "follows updates" unpins it), and
`instance version <instance> <tag|latest>` does it from the CLI. play,
`play --instance` and MCP play use the pin; a pack's Play uses its instance's.
The sidebar shows a pinned instance's version, or that it is not installed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:25:31 +01:00
Ludovic MarieandClaude Opus 5.5 a76456b036 Launcher: instances in the sidebar, and a dialog to make and edit them
The sidebar lists the instances by group with their icons (a texture from the
player's prepared files, drawn as pixels, or a colour); clicking one selects it
for Play. + makes one, empty or a copy of the selected one (copied in the
background); Home's Instance row edits its name, group and icon, copies it, and
opens its folder. The dialog's name and group are the launcher's shared text
fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:21:56 +01:00
Ludovic MarieandClaude Opus 5.5 6087b01f66 test_friends: put the guest on cleared, flat ground before it walks
Since #2501 a player new to the world spawns at a random free spot near the
world spawn, as vanilla's PlayerSpawnFinder does. The guest often stood against
a hill, so walking forward moved it less than a block, or terrain hid the block
it had to aim at: friends_integration failed on every run on the Mac mini.

The host now clears a floor beside itself and teleports the guest there before
the movement checks, and the block check waits for the guest to stand still.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:21:06 +01:00
Ludovic MarieandClaude Opus 5.5 1d0d827f3a Launcher: instances, each with its own game folder
The game's folder stays the default instance, "Horizon", where it is; imported
and registry modpacks are listed as instances in their own folders; new ones
go to instances/<slug>/ in the game's folder, empty (the player's options and
offline name) or a copy of another (never sessions, logs or telemetry). Names,
icons and groups live in the launcher's instances.json, so nothing moves.
Play, `play --instance` and MCP play start the selected instance, which
follows the launcher's data collection answer and No AI. Crash reports are
read from the run's own folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:13:35 +01:00
Ludovic MarieandClaude Opus 5.5 844441874a docs: workspace crate features in a split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:11:27 +01:00
Ludovic Marie b6dd6c35ae Merge remote-tracking branch 'origin/main' into perf/parity-manifest-aware 2026-10-27 01:10:36 +01:00
Ludovic MarieandClaude Opus 5.5 1af4d0e432 parity: a workspace crate's features count through the sources they gate
A manifest line defining or turning on a workspace crate's feature no longer
makes the manifests count, if every source naming the feature (or one it turns
on) changed too, as in a split, where the new crate's files are all new.
#2215 (horizon-items with a test-support feature) now selects 209 harnesses
instead of 354.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:10:36 +01:00
Ludovic Marie 68c9597237 Merge remote-tracking branch 'origin/main' into perf/parity-one-client-profile 2026-10-27 01:09:18 +01:00
Ludovic MarieandClaude Opus 5.5 bedba4a3d9 lan_e2e fireworks: count newly red pixels, take the later shots 4 ticks after the burst
On playhorizon (frames drawn on the CPU) a screenshot takes ~0.6 s: with a
16-tick wait the later shots came after the sparks' lifetime, and the setup's
own red chat lines, gone by then, made the host's count negative.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:08:50 +01:00
Ludovic MarieandClaude Opus 5.5 0db65d7a61 Cargo.toml: one workspace member per line
Each new crate then adds one line instead of rewriting the whole list, so
PRs that add crates at the same time stop conflicting (the crate split's
#2160 conflicted on it three times).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:06:57 +01:00
Ludovic Marie aef652c8af Merge pull request #2552 from ludovic111/ci/remote-fireball-not-flaky
🤖 Agent: remote_fireball passed 4 of 4 under load on playhorizon since #2499; off the known-flaky list.
2026-10-27 00:52:39 +01: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
Ludovic Marie 4be5c8eb53 Merge pull request #2196 from kacpherek/fix/axolotl-squid-water-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Axolotl.handleAirSupply` (6000 air, refilled by water or rain, `dry_out` damage at -20) and `Squid.hurtServer`'s `spawnInk`. I pushed two fixes: `makeSound`'s voice pitch is drawn even when the squid is silent, so the ink's random draws stay in vanilla's order; and rain now uses the biome's height-adjusted temperature (`Biome.getTemperature` with the frozen modifier and the drop above sea level, `Level.isRainingAt`), not the base temperature, so an axolotl high up a mountain is not refilled by snow. On main with this head: clippy `-D warnings` clean and the 1186 horizon-client lib tests pass. CI parity runs the harnesses this head reaches. Thanks @kacpherek!
2026-10-27 00:09:19 +01:00
Ludovic Marie 6ed9e9632d Merge pull request #2557 from ludovic111/ci/full-parity-playhorizon
🤖 Agent: playhorizon takes PR parity jobs and full-parity ones; the desktop's runner is retired (maintainer's request).
2026-10-27 00:05:55 +01: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
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
Ludovic MarieandClaude Opus 5.5 111db2827b ci: full-parity and PR parity jobs on playhorizon, not the desktop
The maintainer asked that CI stop using his desktop (2026-10-26). playhorizon's
runner now carries horizon-game and the desktop's runner none of the job labels;
full-parity jobs go to playhorizon only, with a 6 h timeout on its 4 CPUs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:46:29 +01:00
Ludovic MarieandClaude Opus 5.5 17f8419acd Merge origin/main into refactor/horizon-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:40:59 +01:00
Ludovic Marie a24a6fa26c Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 23:40:35 +01:00
Ludovic MarieandClaude Opus 5.5 9555f375d4 parity: remote_fireball is no longer known flaky (#2499)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:31:29 +01:00
Ludovic MarieandClaude Opus 5.5 1b1571ed6a parity: a crate split's manifest changes don't select every harness
Cargo.lock is a shared input and every harness depends on its crates'
Cargo.toml, so a crate split (#2160) selected all 352 harnesses although
its moved code reaches 123. With --changed, Cargo.lock and Cargo.toml
changes that only add, remove or move workspace crates (no registry or git
package, version, feature, dependency or build setting changed across them,
compared with the merge base) are left out; the moved code counts through
its own sources. Any other manifest change still counts, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:30:59 +01:00
Ludovic MarieandClaude Opus 5.5 a891037c9f parity: harnesses build horizon-client in the fast profile only
Under parity.py the release and fast profiles have identical settings
(PROFILE: no LTO, 16 codegen units, incremental), yet 38 harnesses built
horizon-client in release and 16 others in fast: every run where the client
changed compiled it twice. They all use fast now. Release stays for
horizon-world's probes, which no fast harness builds.

Measured on playhorizon (4 CPUs), all 13 probe builds after 78 merges:
1578 s, of which the release client (bin + client_probe) took 485 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:24:54 +01:00
FusselTVandClaude Opus 5.5 266c7a2cd9 Worldgen: finalize structure mobs at placement, as vanilla
Structure mobs (bastion piglins, brutes and hoglins, ocean ruin drowned,
village and mansion mobs, witch huts, monuments) were tagged and finalized
at load by the cut-down spawning routine: no STRUCTURE reason (piglin
weapons rerolled), no Mob step (follow range bonus, left-handedness), no
baby hoglins, no brain memories, no drowned nautilus mounts, and draws
from each mob's own random. The decoration region now calls a hook the
game installs in the generator workers, which runs mobs::finalize with
Reason::Structure, the region random in placement order and the level's
difficulty with no inhabited time; jockeys come back as the vehicle with
its Passengers. A mob the hook cannot finalize keeps its tag and is
finalized on load as before, as are mobs of older saves; other entities
drop the tag. The generator gets the level's difficulty as soon as the
level opens, so the spawn area's mobs use it too. mob_to_nbt writes Invulnerable and LeftHanded always, IsBaby
for zombies and piglins, Age for hoglins, the finalized attributes and
memories, and drops the template keys vanilla never saves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:12:22 +01:00
Ludovic Marie a727f617f0 Merge pull request #2532 from ludovic111/feat/sculk-sidebar
🤖 Agent: merging. Ludovic asked for the Sculk banner visible on open without scrolling, hideable after a while. Launcher run with isolated homes: banner at the sidebar's foot, HIDE after 3 min seen; clippy -D warnings and 137 launcher tests pass.
2026-10-26 23:05:17 +01:00
Murillo DeolinoandCodex faeff96cf9 Add Wayland launcher window controls
Co-Authored-By: Codex <[email protected]>
2026-10-26 18:59:32 -03:00
Ludovic MarieandClaude Opus 5.5 12bf15cfd7 launcher: the sidebar's partner banner can be hidden once it has been seen
A "Hide" shows beside its label after the banner has been on screen for
3 minutes, counted over every start (settings: partner_seen_secs, saved
each minute). Hiding it is kept (partner_hidden); the Servers page keeps
its own banner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:58:09 +01:00
Ludovic MarieandClaude Opus 5.5 b0783dac38 launcher: Sculk's banner at the foot of the sidebar, seen without scrolling
On Home it sat under every row, so nobody saw it without scrolling. It now
sits small (228 px wide) above the Discord and Website links on every page
but Servers, which keeps the large one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:54:33 +01:00
Ludovic Marie 840d11a3b2 Merge pull request #2499 from ludovic111/fix/remote-fireball-ticks
🤖 Agent: test-health-1: remote_fireball flake fix (slow fireball, one ctl script; 4/4 on playhorizon); checks green.
2026-10-26 22:44:46 +01:00
Ludovic Marie 4504223265 Merge pull request #2502 from ludovic111/ci/full-parity-desktop
🤖 Agent: test-health-1: full-parity label routes a PR's parity job to the desktop runner (verified on this PR); checks green.
2026-10-26 22:39:32 +01:00
Ludovic Marie 774fa9d897 Merge pull request #2186 from kacpherek/fix/soul-speed
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3 Soul Speed: the speed on soul blocks, no soul sand slowdown, the particles and sound, and boot wear on 4% of ticks, now also for each player on a server (`guest_server_tick`), with the particle sent to the players around. I pushed one fix: the random draws come in vanilla's order (the 0.04 wear roll first, then the particles, then the 0.35 sound roll and its pitch). It then conflicted with main (#2190 added the elytra glide wear in the same tick), so I merged main in: Soul Speed comes first, as `LivingEntity.baseTick` runs before `aiStep`, then the glide wear, so both draw from the level random in vanilla order. On that merge: clippy `-D warnings` clean, horizon-client tests pass, the fast build links, and `lan_e2e.py` elytra, damage and breath pass with the host in-frame and on its server thread. CI parity runs the harnesses this head reaches. Thanks @kacpherek!
2026-10-26 22:39:29 +01:00
Ludovic Marie 0c7f6b7f0b Merge pull request #2446 from ludovic111/feat/official-recipes-mod
🤖 Agent: #2318 has merged and this PR's base is main. The official Recipes mod (needs API level 6): pr_ready --quick green; the e2e passes 25/25 in-frame and on a dedicated server on this head (61 of 63 in five modes before; the 2 failures were #2501's bug, now merged). Queued at 92e3f496f.
2026-10-26 22:33:36 +01:00
Ludovic Marie c1a811c4bb Merge pull request #2318 from ludovic111/feat/mod-recipe-actions
🤖 Agent: #2306 has merged and this PR's base is main. recipes.place and recipes.give at API level 6, give cut to one stack. Sandbox (31), api_reference and clippy -D warnings pass; the actions e2e passed 58/58 in five modes. Queued at bd1436f16.
2026-10-26 22:31:39 +01:00
Ludovic Marie 19bb6c1262 Merge pull request #2306 from ludovic111/feat/mod-recipe-pages-game
🤖 Agent: #2305 has merged and this PR's base is main. Recipe pages with station tabs and an ellipsis on a long title; clippy -D warnings with --features mods and --no-default-features is clean, and the browser e2e passed 98/98 in five modes. Queued at 0cba2edbd.
2026-10-26 22:29:19 +01:00
Ludovic Marie 9bf853804e Merge pull request #2481 from ludovic111/perf/prefetch-world-data
🤖 Agent: verify passed (incl. Linux clippy for the glibc malloc_trim path); chunk_generation, worldgen, worldgen_random, save_roundtrip and world_reopen pass on this head; pr_ready --quick green. Preload starts on world select per the orchestrator's decision. Perf fix, no label.
2026-10-26 22:29:16 +01:00
Ludovic Marie 7e4ddee8b1 Merge pull request #2305 from ludovic111/feat/mod-recipe-pages
🤖 Agent: #2301 has merged and this PR's base is main. browser.set-recipes (with station tabs) at API level 6; sandbox (31), api_reference and clippy -D warnings pass. Queued at a373eea18.
2026-10-26 22:27:04 +01:00
Ludovic MarieandClaude Opus 5.5 f8ba650559 ci: full-parity label runs a pull request's parity job on the desktop
A crate split reaches every harness (#2160: 352 harnesses, 71 min on a Mac
mini runner) and held the Mac mini's runners while other pull requests'
parity jobs queued for over an hour. With the full-parity label, the job
runs on the desktop's runner only (8 at a time) and waits for it while the
desktop is in use; its timeout is 300 min, since a job under way when
someone comes back goes on with 2 threads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:25:11 +01:00
Ludovic Marie 92b99c58aa Merge pull request #2301 from ludovic111/feat/mod-item-panel
🤖 Agent: #2300 has merged and this PR's base is main. Merged up the renumbered stack (browser at API level 6); clippy -D warnings with --features mods and --no-default-features is clean, and the browser e2e passed 98/98 in five modes before. Queued at a2cd70e8e.
2026-10-26 22:24:13 +01: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
Ludovic Marie 7e82902d15 Merge pull request #2300 from ludovic111/feat/mod-item-panel-api
🤖 Agent: #2284 has merged and this PR's base is main. browser is at API level 6 (recipes took 5). Sandbox (31, the UI guest at level 6), api_reference and clippy -D warnings pass. Queued at b836eea06.
2026-10-26 22:21:58 +01:00
Ludovic Marie d090686ffa Merge pull request #2500 from ludovic111/tools/remote-cleanup
🤖 Agent: test-health-1: remote.sh stops the host command with its caller and cleans what it leaves; checks green.
2026-10-26 22:21:54 +01:00
Ludovic Marie aba4f19a81 Merge pull request #2284 from ludovic111/feat/mod-recipes-read
🤖 Agent: #1895 has merged and this PR's base is main. Merged main: recipes moves to API level 5 (world took 4). Sandbox (30, including the level assertion), api_reference and clippy -D warnings on horizon-mods pass. Queued at b360e3c26.
2026-10-26 22:19:22 +01:00
Ludovic Marie 5a1d1c38ed Merge pull request #2501 from ludovic111/fix/lan-guest-stations
🤖 Agent: LAN guests (host on its server thread) and a dedicated server's new players joined inside each other on the exact spawn, so they clicked the other player instead of a table or chest. New players now get PlayerSpawnFinder.findSpawn as in vanilla. lan_e2e spawned/crafting/chest pass in both host modes, a dedicated two-guest check passes, and the 32 affected harnesses pass.
2026-10-26 22:19:19 +01:00
Ludovic MarieandClaude Opus 5.5 cc58083574 Give the dropped preload's memory back to the OS
glibc keeps the pages the preload thread freed in that thread's arena, and
macOS keeps them in its zones until memory runs short, so dropping the
preload didn't lower the footprint: horizon-threads now asks the allocator to
return free pages (malloc_trim, malloc_zone_pressure_relief).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:14:56 +01:00
Ludovic Marie aa0eb406d7 Merge pull request #2496 from ludovic111/fix/dedicated-server-steps
🤖 Agent: test-health-1: dedicated_server steps wait for the server's reply (flake fix, passes under injected stalls); checks green.
2026-10-26 22:14:45 +01:00
Ludovic Marie c5534f0d7b Merge remote-tracking branch 'origin/main' into feat/server-mods-commands
# Conflicts:
#	crates/horizon-mods/src/lib.rs
2026-10-26 22:14:09 +01:00
Ludovic MarieandClaude Opus 5.5 8f02822a9a remote.sh: stop the command on the host with its caller, and what it leaves running
The command ran on after remote.sh was stopped or its connection dropped,
holding the name's lock, and a game or daemon it left behind outlived the
run. It now runs in a scope named after the run (remote-<name>.scope),
stopped when the command ends, when the ssh session ends first, and before
the next run under that name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:09:44 +01:00
Ludovic MarieandClaude Opus 5.5 031f1e58a3 remote_fireball: a slow fireball, sampled from one ctl process
The harness sampled the fireball from a new horizon-ctl process each time,
10-16 ticks apart on a loaded runner: the fireball (~2 blocks a tick) left
the cleared corridor or the guest's tracking range before the sixth sample,
and the harness failed. It now flies slowly (acceleration_power 0.02, under
0.4 blocks a tick) down a longer corridor, and the guest takes 16 samples
in one ctl script (lan_e2e's Game.script returns every reply with every=True).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:07:12 +01:00
Ludovic MarieandClaude Opus 5.5 d3a662b760 Merge origin/main into refactor/horizon-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:06:35 +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 Marie 92e3f496f0 Merge branch 'feat/mod-recipe-actions' into feat/official-recipes-mod
# Conflicts:
#	Cargo.toml
2026-10-26 22:01:02 +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 Marie bd1436f162 Merge branch 'feat/mod-recipe-pages-game' into feat/mod-recipe-actions
# Conflicts:
#	crates/horizon-mods/src/api_level.rs
#	crates/horizon-mods/src/manifest.rs
#	docs/api/mods-reference.md
#	docs/api/stability.md
2026-10-26 22:00:44 +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
Ludovic MarieandClaude Opus 5.5 1fe0f5fcb9 Sound options: Music Frequency starts its own row, as in SoundOptionsScreen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:39 +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
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
Ludovic MarieandClaude Opus 5.5 c4d0cb1ec0 A small armor stand's eyes are at 0.9875, and eye heights are floats
ArmorStand.BABY_DIMENSIONS keeps its own eye height rather than half the
type's, and EntityDimensions.eyeHeight is a float widened by getEyeY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:55:51 +01: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
Ludovic Marie 0cba2edbd4 Merge branch 'feat/mod-recipe-pages' into feat/mod-recipe-pages-game 2026-10-26 21:52:49 +01:00
Ludovic Marie d6a80c5193 Merge pull request #2489 from ludovic111/tools/remote-linux
🤖 Agent: test-health-1: tools/remote.sh runs agents' Linux work on playhorizon (fetch from GitHub, no secrets sent); verify green.
2026-10-26 21:52:49 +01:00
Ludovic Marie a373eea189 Merge branch 'feat/mod-item-panel' into feat/mod-recipe-pages
# Conflicts:
#	crates/horizon-mods/src/api_level.rs
#	docs/api/stability.md
2026-10-26 21:52:43 +01:00
Ludovic Marie a2cd70e8e1 Merge branch 'feat/mod-item-panel-api' into feat/mod-item-panel
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-26 21:51:29 +01:00
Ludovic MarieandClaude Opus 5.5 331e3a7202 dedicated_server: steps wait for the server's reply, not a 1.5 s timer
The parity client's chat, villager and furnace steps went on 1.5 s after
sending, so a busy server's late reply landed in the next step (or a click
carried a stale state id). Each step now waits until the server answered:
its chat line, both slot packets of a shift-click, and the furnace totals
the click changes (up to the same 30 s patience). The respawn window keeps
only the player's own entity packets: a squid's swim event or a mob coming
into view falls into it as each server's ticks fall.

Off the known-flaky list: it passes under 2.5 s server stalls every 5 s,
where main's client fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:49:24 +01:00
Ludovic MarieandClaude Opus 5.5 b836eea062 Merge #2284 (recipes now at API level 5): browser moves to level 6
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:47:46 +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 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
Ludovic Marie 6d9bb375c5 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 21:43:18 +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 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
Ludovic MarieandClaude Opus 5.5 84f8205bdd Merge main into fix/1482-evoker-fangs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:30:40 +01:00
Ludovic Marie effb29e571 Merge pull request #2480 from ludovic111/perf/requeue-on-centre-change
🤖 Agent: verify passed; chunk_generation, worldgen, worldgen_random, save_roundtrip and world_reopen pass locally on this head plus #2481; pr_ready --quick green. Perf fix, no label.
2026-10-26 21:28:37 +01:00
Ludovic MarieandClaude Opus 5.5 26561d315c Preload the world data while the player chooses a world
The worldgen bundle, the loot tables and the registries a server sends are
read on a helper thread from the moment the player opens Singleplayer or
Create World, so a world opening finds them ready. A world opened meanwhile
waits for the part being read. If the player goes back to the title or to
Multiplayer and opens no world for a minute, the preload is dropped: the three
caches can now let go of what they hold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:26:47 +01:00
Kacpherek 65c60ab996 Play remote sounds in the category the packet names 2026-10-26 21:23:38 +01:00
Kacpherek 56bf45cee5 Keep the pack screen selection in step with High Contrast 2026-10-26 21:23:03 +01:00
Ludovic MarieandClaude Opus 5.5 4afd517c22 tools/remote.sh: run a command on playhorizon at a worktree's commit
The host fetches the commit from GitHub (nothing else is sent), keeps one
checkout and target per worktree, runs the command niced in its CI slice on
CPUs 4-7 with scratch homes, prepares vanilla data for harness commands as
CI does, and streams the output back. One run per name at a time; no lock
leaks into daemons the run starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:22:46 +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 0e96fb6d2a Disable unavailable options in the Graphics Advanced screen 2026-10-26 21:21:06 +01:00
Kacpherek f4a235e027 Record a witch potion source only when it deals damage 2026-10-26 21:20:45 +01:00
Ludovic Marie cb43e32787 Merge pull request #2479 from ludovic111/tools/pr-ready-like-ci
🤖 Agent: test-health-1: pr_ready now runs CI's clippy targets and features, the server clippy pass and the Python tests; verify green.
2026-10-26 21:16:48 +01:00
Ludovic MarieandClaude Opus 5.5 67c27701e9 Server: new players join around the spawn, not all on one spot
The server put every player new to the world exactly on the world spawn.
On the server thread the host's own player joins that way too, so the host
and each new guest stood inside each other: the guest's client targets
the host's player (its eye inside that box, as vanilla's getEntityHitResult
does), so it could open no crafting table, chest or furnace, and Entity.push
never parts two players with the same centre. A dedicated server stacked
its new players the same way.

Vanilla places each new player with ServerPlayer.adjustSpawnLocation:
PlayerSpawnFinder.findSpawn, a free column at a random offset within the
respawn radius. Game::server_player_spawn does it for a server's new
players with the window's own finder, passing over the candidates whose
chunk isn't loaded yet (vanilla waits for them; a server tick can't).

lan_e2e's new check `spawned` asserts that the host and the guest join on
different spots, then has the guest open a crafting table, a chest, a
furnace and a stonecutter where it joined.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:16:43 +01:00
Ludovic MarieandClaude Opus 5.5 69bb813128 game: cut a recipe page's title with an ellipsis
A title wider than the panel ended at its edge mid-word ("Recipes: Oak P");
it now ends with vanilla's "..." (CommonComponents.ELLIPSIS).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:16:34 +01:00
Ludovic MarieandClaude Opus 5.5 b360e3c26c Merge main (#1895: world takes level 4): recipes moves to API level 5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:14:13 +01: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
Ludovic Marie 9312411e53 Merge pull request #2451 from ludovic111/ci/parity-known-flaky
🤖 Agent: merging #2451 (test-health-1): verify and parity passed at e82fa3d95.
2026-10-26 21:00:51 +01:00
Ludovic Marie 9cc9792b28 Merge pull request #2448 from ludovic111/ci/parity-merge-main
🤖 Agent: merging #2448 (test-health-1): verify and parity passed at df045f1e9.
2026-10-26 21:00:47 +01:00
Ludovic MarieandClaude Opus 5.5 37559dae82 pr_ready: run CI's clippy, CI's test features, Python tests and launcher rule
pr_ready --quick passed #1895 while CI's clippy failed on an unused import in horizon-mods' tests: clippy on the
touched crates and their dependents gives them other features than CI's workspace-wide run (Cargo unifies
features over the packages it builds).
- clippy: CI's two commands as they are. The whole workspace runs with the game's code mods, and the server build
  (horizon-client --no-default-features) runs when horizon-client is affected. Warm on the Mac mini, a change to
  hud.rs took 14-16 s against 17 s before, and food.rs 22-32 s against 14 s. Each worktree's first run costs
  about 3 min once.
- tests stay on the touched crates, with the features CI's workspace run gives them: horizon-client's code mods,
  and horizon-mods' runtime, pack and index.
- The Python tests always run, as in CI: some read the crates, the roadmap or the workflows.
- The launcher steps run when CI's launcher job would (tools/ci/rust_changes.py's rule, which also covers the
  root manifests and the check itself).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:58:06 +01:00
Ludovic Marie fff9cd1580 Merge pull request #1895 from ludovic111/feat/server-mods-world
🤖 Agent: merged main for #2316 (the campfire fix: this head had #2015 without it, so dispenser and block_removal would fail parity). Clippy -D warnings on horizon-mods (all targets, all features) and on horizon-client --features mods --bins is clean. Requeued at 8ec47d641.
2026-10-26 20:55:03 +01:00
Ludovic MarieandClaude Opus 5.5 bb9b0424b4 Axolotl rain: the biome's height-adjusted temperature
Level.isRainingAt takes Biome.getHeightAdjustedTemperature, which applies
the frozen oceans' temperature modifier and the height noise above the snow
level; reuse horizon-world's version instead of a simplified copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:52:11 +01: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
Ludovic Marie 565cf6af4e Merge pull request #2205 from kacpherek/fix/turtle-eggs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: turtles lay (`TurtleLayEggGoal` with the digging effect), `TurtleEggBlock` hatches, and a growing baby drops a scute. I pushed one fix: the home is set at spawn and kept on load (`finalizeSpawn`, `home_pos`), and breeding drops one orb of 1 to 7, drawn from the mother's random and only with `mob_drops`. After #2137 the gift loot event names its mob. I merged main into your branch (6ef61ac13): main's `Reinforcements` stays beside your `BlockBreakEffect`, and the grown scute now goes through #2137's `drop_gift_loot` (the turtle's id added to both `GiftLoot` events) behind your mob_drops check; clippy clean, same code as the tested tree. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:43:59 +01:00
Kacpherek 34dabe4afd Grey out Chunk Fade-in, Improved Transparency and Chunk Builder 2026-10-26 20:41:49 +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 ad6f2320b0 Wire the Music Frequency option to the music manager 2026-10-26 20:40:06 +01:00
Ludovic Marie b6a2bb2c0d Merge pull request #2285 from ludovic111/fix/loot-tables-before-chunks
🤖 Agent: Merged by Ludovic's AI agent (Claude). The loot tables now load before a world's first chunks are read, so a loot chest near spawn gets its roll. The new spawn_loot harness fails on main (0 stacks) and passes here with vanilla 26.3's exact roll, on the server thread and in-frame. I merged main after #2316; dispenser and block_removal pass again. Follows @FusselTV's #2073.
2026-10-26 20:39:09 +01:00
Kacpherek 4201fd23c0 Use real eye heights in /execute and keep the anchor on positioned as 2026-10-26 20:39:05 +01:00
Ludovic MarieandClaude Opus 5.5 8920f08fd9 Streaming: rank queued jobs again when the centre or the loading screen changes
Jobs queued while the loading screen was up carried ring-order priorities,
far above the plain distances used once it closed, so they waited behind
every later job. The proto chunks they generated held back the decoration
of the chunks around the player: at render distance 32 in-frame, nothing
was decorated until every proto chunk had been generated. Queued jobs now
keep their column and bias and are ranked again from the current centre,
extra areas, band and loading screen when those change, as vanilla's
chunk task queue follows the current ticket levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:39:04 +01:00
Kacpherek 46b71954e1 Make the High Contrast option turn its resource pack on and off 2026-10-26 20:38:38 +01:00
Kacpherek b92a53e47c Swing and pose the main arm of a left-handed mob 2026-10-26 20:37:52 +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
Ludovic MarieandClaude Opus 5.5 6ef61ac13b Merge origin/main into the turtle branch
Keeps main's Reinforcements beside BlockBreakEffect, and the turtle's grown
scute goes through #2137's drop_gift_loot (the mob's id added to its two
GiftLoot events) behind the mob_drops check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:31:43 +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
Ludovic Marie 4d6875ecfb Merge pull request #2201 from kacpherek/fix/raider-and-zombie-doors
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BreakDoorGoal` (the difficulty gate, 240 ticks, the crack progress that your last commit now sends to nearby players) and the vindicators' door rules in raids. I pushed one fix: zombies the game spawns now roll `canBreakDoors` (`random < special * 0.1`), as `Zombie.finalizeSpawn` does. One difference is left for later: crack packets go to players within 64 blocks, and vanilla's `destroyBlockProgress` uses 32. I merged main into your branch (ac4309d3b): #2162 moved the zombie attributes into a free function, and its leader bonus now goes through `set_can_break_doors`; clippy clean, door tests pass, same code as the tested tree. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:28:41 +01:00
Ludovic MarieandClaude Opus 5.5 e82fa3d957 parity: retry a known flaky harness once, and say so
tools/parity_hosts.json's new `known_flaky` lists the harnesses that still flake for a cause not fixed yet,
each with its reason: dedicated_server (its Java client's 1.5 s step timer), remote_fireball (wall-clock
sampling) and world_reopen. One of them that fails runs once more from scratch, and a pass then counts.

Every retry prints a RETRIED line at the end of the run and in the CI job's summary, so a harness that
passes only on its retry stays visible on PR parity and parity-main alike. The existing fresh rerun of a
failure that reused oracle results is reported the same way. A known flaky harness skips that rerun and keeps
reusing oracle results, because flaking doesn't mean its cached oracle runs were wrong. Repeated runs
(--repeat, --flake-check) are never retried.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:27:34 +01:00
Ludovic MarieandClaude Opus 5.5 d026c6db3e Merge origin/main into fix/soul-speed
Keeps #2184's glide wear with Unbreaking after Soul Speed's tick, the order
of LivingEntity.baseTick and aiStep (both draw from the level random), and
both the can_glide_through and soul_speed_blocks tags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:26:50 +01:00
Ludovic Marie e704a8dccb Merge pull request #2187 from kacpherek/fix/shulker-bullet-duplication
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Shulker.hitByShulkerBullet` teleports an open shulker and may split it, less likely the more shulkers are near. I pushed one fix: the split roll uses the level's random, as vanilla's `level.getRandom()` does. Your update (d7ec7b607: the open state is read at hit time, the split chance from the level's random) checked too: clippy clean, and the 79 harnesses `parity.py --changed-files` picks pass apart from the campfire cases #2316 fixes. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:24:29 +01:00
Ludovic Marie 1975fe486d Merge pull request #2190 from kacpherek/fix/piglin-loved-items
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `PiglinAi.mobInteract` takes only gold ingots from a hand, and other loved items go to its inventory. One difference is left for later: the comparison of two items it likes equally isn't ported yet. Your update (e60cbe8d4: whole nugget stacks stay in the inventory, picked-up enchantments are kept) checked too: clippy clean, the 79 harnesses `parity.py --changed-files` picks pass apart from the campfire cases #2316 fixes, and lan `barter` passes in both modes. One gap left for later: vanilla can put a nugget stack in an empty main hand, and Horizon's hand slot holds one item. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:24:25 +01:00
Ludovic Marie 98e29159eb Merge pull request #2184 from kacpherek/fix/elytra-glide-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.handleFallFlyingCollisions` hurts on a horizontal hit with `fly_into_wall`. Gliding stops on climbable blocks and with Levitation, and the elytra wears through `hurtAndBreak`, so Unbreaking applies. Your update (a4407a4bc: Unbreaking for guests, wall hits kept on the server copy) checked too: clippy clean, the 79 harnesses `parity.py --changed-files` picks pass apart from the campfire cases in `dispenser` and `block_removal` that #2316 fixes, and lan `elytra` passes in both modes. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:24:21 +01:00
Ludovic MarieandClaude Opus 5.5 df045f1e9a CI parity: test the head merged into main
A head tested alone keeps failing harnesses that main already fixed. Since 10-25 evening, 9 of 21 failed PR
parity jobs failed only on such fixes: server_knockback (heads without #2114, 7 times), mob_ai's oracle heap
(without #2270), save_roundtrip (without #2212). #2160 needed 4 parity runs this way.

The job now merges main's current tip into the head and selects harnesses against that main commit, so only
the pull request's own changes count. A branch that doesn't change the runner scripts or the selection gets
main's, which replaces the two steps that copied main's vanilla-data.sh and parity.py in by hand. A head that
conflicts with main is tested alone, with a warning. The job summary names the main commit, and the check is
still `parity / parity` on the head.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:22:13 +01:00
Ludovic Marie 55a71d3502 Merge pull request #2164 from kacpherek/fix/witch-drinking
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Witch.aiStep` stands still while drinking (the -0.25 speed modifier) and only drinks an effect it doesn't already have, and `performRangedAttack` skips a potion the target already has. It merges after #2138, since both add fields to `Seen`. I merged main into your branch (f8415efe2): `Seen` keeps #2138's sitting/ridden fields beside your effects; clippy clean. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:19:47 +01:00
Ludovic Marie 02cd2254d0 Merge pull request #2157 from kacpherek/fix/armor-wear-environment-damage
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: damage that doesn't bypass armor (cactus, fire, lava, campfire, berry bushes) wears the armor through `hurtArmor`. Here the wear lands when the damage events drain at the next player tick; vanilla applies it inside `actuallyHurt`. That is close enough for now. #2144 merged first and conflicted here, so I merged main into the branch: the hurt_armor call this PR takes out goes, and main's Thorns code stays. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:19:43 +01:00
Ludovic Marie 1132d2986a Merge pull request #2199 from kacpherek/fix/xp-orbs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the `ExperienceOrb` pickup box, merging by count, and the lava bounce. Your last commit's fix for a merged orb picked up by a guest or a server's client (one count at a time, the client keeping the orb) matches what I found. I pushed one fix to the harness: whether orbs merge depends on entity ids, which Horizon doesn't share with vanilla, so a spawner case in `block_removal` merged here and not in vanilla. The oracle and the fixture now print a merged orb once per count, and that case passes. (`block_removal`'s campfire cases fail on main until #2316 lands.) Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:19:39 +01:00
Ludovic Marie 1cdc8ca76b Merge pull request #2192 from kacpherek/fix/piglin-soul-fire-repellents
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `PiglinSpecificSensor` finds the nearest `#piglin_repellents` block (a soul campfire only while lit), and the piglin walks away from it. I pushed one rename: this PR and #2191 each added a `find_repellent` to piglin.rs, so this one is now `find_piglin_repellent`. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:16:50 +01:00
Ludovic Marie a1098c0769 Merge pull request #2210 from kacpherek/fix/2174-empty-hand-place-sound
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BlockItem.place` plays the place sound only when a block item places a block; an empty hand or another item plays none. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:16:46 +01:00
Ludovic Marie 73993e7cda Merge pull request #2204 from kacpherek/fix/conduit-power
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ConduitBlockEntity.serverTick` (the frame every 40 ticks, Conduit Power in range, the attack on the nearest enemy), and your last commit's server ticker, which runs without a renderer. One difference is left for later: the conduit's target isn't synced to remote clients yet. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:16:42 +01:00
Ludovic Marie 53a95a6e1c Merge pull request #2203 from kacpherek/fix/illagers-spare-baby-villagers
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the illagers' target goals skip baby villagers. One difference is left for later, older than this PR: the illagers' shared hurt-by list still includes Vex. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:16:39 +01:00
Ludovic Marie 3ec6c73037 Merge pull request #2202 from kacpherek/fix/evoker-and-vex-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the evoker's vex summon and cooldowns, the fang lines and circle, the vex limits, and the wololo spell (blue sheep only, with `mob_griefing`). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:16:35 +01: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
Ludovic Marie 159f2bf890 Merge pull request #2200 from kacpherek/fix/item-drop-direction-and-creative-stand
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.drop` throws leftovers from the eyes, and `ArmorStand.swapItem` lets a creative player keep their stack. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:13:01 +01:00
Ludovic Marie 6628d8b950 Merge pull request #2198 from kacpherek/fix/cactus-destroys-items
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `CactusBlock.entityInside` hurts any entity with `cactus` damage, and that destroys item entities. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:12:57 +01:00
Ludovic Marie ce5be7ffa1 Merge pull request #2197 from kacpherek/fix/chest-blocked-above
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ChestBlock.isChestBlockedAt`: a block above that is a redstone conductor, or a sitting cat on top. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:12:53 +01:00
Ludovic Marie 2806902c93 Merge pull request #2195 from kacpherek/fix/guardian-beam-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the guardian's beam deals 2 more on Hard and 2 more from an elder, and squid and axolotl targets must be over 3 blocks away (`GuardianAttackSelector`). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:12:49 +01:00
Ludovic Marie 8839f0bc72 Merge pull request #2137 from kacpherek/fix/chicken-eggs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Chicken.aiStep` lays through `dropFromGiftLootTable(gameplay/chicken_lay)` with the chicken as `this`, which the table's conditions need, and snow and chorus flowers drop their loot when a player breaks them. One difference is left for later: an explosion's drops don't pass the exploding entity as `this` yet (snow broken by TNT). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:12:45 +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
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
Ludovic Marie 0f726a9f7c Merge pull request #2194 from kacpherek/fix/turtle-helmet-water-breathing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the turtle helmet gives Water Breathing for 200 ticks while the eyes are out of water (`Player.turtleHelmetTick`). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:08:08 +01:00
Ludovic Marie f73cfff300 Merge pull request #2193 from kacpherek/fix/fish-buckets
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Bucketable.bucketMobPickup` for cod, salmon, pufferfish, tropical fish, tadpoles and axolotls, with the data each bucket carries (salmon size, tropical fish pattern and colours, the age only for axolotls and tadpoles) and the pattern names in enum order. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:08:03 +01:00
Ludovic Marie 6ac5d148ec Merge pull request #2178 from kacpherek/fix/lightning-rod-attraction
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerLevel.findLightningRod` takes the closest lightning rod POI within 128 blocks, at the top of its column. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:07:59 +01:00
Ludovic Marie 5689e63500 Merge pull request #2169 from Domanffe/perf/hotbar-no-clone
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked: the hotbar now draws its stacks by reference. The output is the same, without a clone per stack each frame. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @Domanffe!
2026-10-26 20:07:55 +01:00
Ludovic Marie d721ea2a44 Merge pull request #2163 from kacpherek/fix/charged-creeper-heads
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: a charged creeper's explosion makes a mob it kills drop its head, once per creeper (`Creeper.canDropMobsSkull` / `increaseDroppedSkulls`). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:07:51 +01:00
Kacpherek efd6c60a27 Axolotl air: refill only where it rains locally, not in deserts or snowy biomes 2026-10-26 20:07:36 +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
Ludovic MarieandClaude Opus 5.5 ac4309d3bf Merge origin/main into fix/raider-and-zombie-doors
#2162 moved the zombie attributes into a free function; its leader bonus now
goes through set_can_break_doors like this branch's other door flags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:03:32 +01:00
Kacpherek a08f3eb2fa Select a bundle's item with the mouse wheel 2026-10-26 20:02:36 +01:00
Ludovic Marie 6e01dac2f9 Merge pull request #2162 from kacpherek/fix/zombie-reinforcements
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Zombie.hurtServer` on Hard rolls `spawnReinforcementsChance` and tries 50 spots around the target (`checkSpawnRules`, no player close, unobstructed), and both zombies lose 0.05 of the chance. Two small differences are left for later: the roll is drawn at the end of the tick rather than inside the hurt, and the new zombie draws one extra yaw. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:02:35 +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
Ludovic Marie f9b2f949ee Merge pull request #2159 from kacpherek/fix/infinity-plain-arrows-only
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: Infinity's `ammo_use` effect only covers `minecraft:arrow`, so tipped and spectral arrows are used up. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:59:37 +01:00
Kacpherek 29eae3a1af Throw a burst of spit particles when a llama spits 2026-10-26 19:58:51 +01:00
Ludovic MarieandClaude Opus 5.5 d241c54e05 Official mod: Recipes, a recipe viewer
Every item sits in the item panel beside the inventory and container
screens, with a search box (words in the name or id, @namespace, #tag).
R or a click shows an item's recipes, U or a right click its uses, with a
tab per station; Back returns through the pages. + beside a crafting
recipe the player knows fills the grid as the recipe book does. In
creative mode, with "Click gives items" on, a click gives one item and a
right click a stack. The keys are rebindable in Controls.

Written from vanilla 26.3's recipe book and screens and Horizon's design
system only. On servers that are not Horizon it shows the game's own
26.3 recipes; known recipes and + follow the server's recipe book.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:58:45 +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
Ludovic Marie 15b38ba030 Merge pull request #2158 from kacpherek/fix/fishing-hook-items
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `FishingHook.canHitEntity` hooks item entities too, its luck is the owner's luck attribute plus Luck of the Sea, and `fish_caught` counts the catch. One difference is left for later: the hook takes the first overlap rather than the closest one on its path. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:56:48 +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
Ludovic Marie 46150ba08c Merge pull request #2156 from kacpherek/fix/buttons-pressed-by-arrows
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ButtonBlock.checkPressed` presses a wooden button while an arrow is inside its shape and keeps it pressed as long as one is there. One difference is left for later: wooden pressure plates and tripwires don't count arrows yet. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:54:07 +01:00
Ludovic Marie 72e678be8c Merge pull request #2155 from kacpherek/fix/target-block-projectiles
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `TargetBlock.onProjectileHit` gives a signal of 1 to 15 from how close the hit is to the face's centre, for 20 ticks from an arrow and 8 from anything else, with the `target_hit` trigger and stat. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:51:09 +01: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
Ludovic Marie 7674ef77cc Merge pull request #2154 from kacpherek/fix/daylight-detector
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `DaylightDetectorBlock.updateSignalStrength` (sky light minus darkening, the sun-angle curve, the inverted mode) and its update when used. Main conflicted with its new campfire block entity, so I merged main into the branch, keeping both block entities. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:48:14 +01:00
Ludovic Marie cddb07c9db Merge pull request #2153 from kacpherek/fix/crossbow-piercing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: Piercing sets the arrow's `pierceLevel`, and `AbstractArrow.onHitEntity` remembers the entities it went through and goes on until the level runs out. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:45:21 +01:00
Ludovic Marie a99e31d01f Merge pull request #2152 from kacpherek/fix/trident-unbreaking-riptide
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: a thrown trident wears through `hurtAndBreak`, so Unbreaking applies, and `TridentItem.releaseUsing` doesn't launch a riding player with Riptide. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:42:31 +01:00
Ludovic MarieandClaude Opus 5.5 b28b499135 mods: recipes.give hands out one stack at most
As the creative inventory does: a count over the item's stack size (64, 16
or 1) is cut to it, so a mod can ask for a full stack without knowing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:41:35 +01:00
Ludovic Marie 97ddd2c6f9 Merge pull request #2151 from kacpherek/fix/trident-channeling
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: channeling.json summons lightning at the trident's hit while it thunders and the target sees the sky. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:39:42 +01:00
Ludovic Marie f01a4e2119 Merge pull request #2150 from kacpherek/fix/villager-bed-search-radius
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `VillagerMakeLove.takeVacantBed` asks the POI manager for a free home within 48 blocks (a sphere, closest first) and keeps only a bed a path reaches. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:36:14 +01:00
FusselTVandClaude Opus 5.5 61df807b33 Structures: a chunk saves its starts also when no piece reaches it
A chunk's STRUCTURE_STARTS are the starts made in it
(StructureManager.getAllStarts), whether or not one of their pieces
reaches it. own_starts filtered the chunk's references, which only hold
starts with a piece in the chunk, so a start whose pieces all lie next
door (seed 8675309's giant ruined portal in chunk -26, 23) was not saved.
The proto chunk now keeps the starts made at its position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:32:44 +01:00
Ludovic Marie 7f8a6680a8 Merge pull request #2149 from kacpherek/fix/farmer-composter-bread
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WorkAtComposter` composts the farmer's seeds through `ComposterBlock.insertItem` and makes bread from wheat. I pushed one small fix: the `ComposterBlock.addLayer` doc comment had moved off its function. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:32:30 +01:00
FusselTVandClaude Opus 5.5 ec81bea5bf Worldgen: blocks with a block entity get vanilla's default one
WorldGenRegion.setBlock gives every block that has a block entity a new
one, so vanilla saves the sculk sensors, catalysts and shriekers of the
deep dark and potent sulfur with their block entities. Horizon only
saved block entities that a structure or feature placed data for, and
these kinds have none, so a Horizon-made world had none of them (3,035
sculk sensors, 753 catalysts, 329 shriekers and 21 potent sulfur over
three 1,024-block seeds). A finished column now adds vanilla's defaults
(block_entity_nbt::canonical) for every such block: as the modelled
block entity where Horizon has one (sculk), as raw NBT otherwise.
Potent sulfur gets its block entity type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:53 +01:00
FusselTVandClaude Opus 5.5 1eb7a04a4e End: no exit portal while nobody is in the fight
With force-loaded levels ticking without players (#1525), Horizon placed
the exit portal as soon as the End's main island was loaded. Vanilla
only places it from scanState and the dragon's death, both with players
in the fight, so a pre-generated End has no portal until someone
arrives.

test_forced_end_parity force-loads the End's arena on vanilla's and
Horizon's dedicated servers with nobody online and compares what is
around 0, 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:44 +01:00
FusselTVandClaude Opus 5.5 55a066e88a End: spike crystals are saved with the chunk that generates them
SpikeFeature.placeSpike adds each crystal to the WorldGenRegion, so a
pre-generated End holds its ten crystals before anyone ticks it.
Horizon's decoration dropped spawn_end_crystal and the game placed the
crystals later, once the arena was loaded and ticking. An End generated
with ticks frozen, as a seed generator does, had none. The decoration
region now writes the crystal like any placed entity; the game's own
placement still skips a spike that has its crystal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
FusselTVandClaude Opus 5.5 8c2ef15171 Falling suspicious sand and gravel break where they land
BrushableBlock.tick makes the falling block entity with disableDrop, so
vanilla breaks it on landing (onBrokenAfterFall: level event 2001 and
the block_destroy game event) and
places nothing. Horizon landed it as a plain block without its
archaeology block entity. Seen in pre-generated worlds: suspicious gravel
of cold ocean ruins that sits over water fell during the first ticks and
landed one block lower without its block entity, so with no loot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
FusselTVandClaude Opus 5.5 705e3d10cf Force-loaded chunks decorate in vanilla's order
Chunks no player ticket reached were decorated nearest the centre first.
Vanilla's ForceLoadCommand adds a FORCED ticket per chunk, x-outer and
z-inner, and the FULL tasks run in that order, so a chunk decorates
before its neighbour at higher x or z. Both orders agree only in the +x,
+z quarter of the map. Elsewhere features crossing chunk borders
overwrote each other the other way round: a geode next door ate a trial
chamber's dispensers, sculk or a clay pool closed a dungeon so it was
never placed, and ore, stone and tree blobs swapped.

The forceload command now ranks the chunks it adds in vanilla's order
(decoration_order::forced_order, which shares its scheduler with
vanilla_order); chunks without a rank still go last, nearest first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
Ludovic Marie 68db8847e7 Merge pull request #2147 from kacpherek/fix/creeper-fire-charge
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Creeper.mobInteract` ignites with any `#creeper_igniters` item. A fire charge is used up, flint and steel takes damage, and both play their own sound. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:29:08 +01:00
Ludovic Marie 3a5aa2b3e7 Merge pull request #2146 from kacpherek/fix/skeleton-attack-interval-difficulty
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AbstractSkeleton.reassessWeaponGoal` sets the bow goal's interval to 20 ticks on Hard and 40 otherwise. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:25:43 +01:00
Kacpherek 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +01:00
Ludovic MarieandClaude Opus 5.5 edc4a58397 Merge origin/main into refactor/horizon-command
Brings #2316 (the campfire block entity fix): CI's parity checks out this
branch's head, so dispenser and block_removal need it here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:22:41 +01:00
Ludovic Marie c2e4eee251 Merge pull request #2145 from kacpherek/fix/mob-griefing-explosions-endermen
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerLevel.explode` turns `ExplosionInteraction.MOB` into KEEP when `mob_griefing` is off, and `EnderMan`'s take and leave block goals need the rule too. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:22:10 +01:00
Ludovic Marie bc13a67e74 Merge pull request #2143 from kacpherek/fix/sweep-attack-damage
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.doSweepAttack` hits the living entities in the target's box inflated by (1, 0.25, 1), not the player, the target or allies, and not marker armor stands. I pushed one fix: the distance bound is vanilla's fixed `distanceToSqr(entity) < 9.0`, not the player's reach. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:21:26 +01:00
Ludovic Marie f6ff2319a5 Merge branch 'feat/mod-recipe-pages-game' into feat/mod-recipe-actions 2026-10-26 19:19:55 +01:00
Ludovic MarieandClaude Opus 5.5 5d7097877b game: station tabs on the recipe pages
A row of tabs above the recipe pages shows `set-recipes`' stations (their
item in a slot, a white frame on the selected one). A click sends `tab(i)`
to the mod, R/U over a tab works like over any slot, and horizon-ctl lists
them as "<name> tab". Changing tab resets the page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:19:30 +01:00
Ludovic MarieandClaude Opus 5.5 f8415efe29 Merge origin/main into fix/witch-drinking
Keeps main's sitting/ridden fields on Seen beside this branch's effects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:19:22 +01:00
Ludovic Marie c35575f462 Merge pull request #2138 from kacpherek/fix/animal-breeding-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: rabbits breed (`Rabbit.isFood`), damage ends love (`Animal.hurtServer` resets `inLove`), the horse and wolf `canMate` checks, and the breeding orb only with `mob_drops` (`Animal.finalizeSpawnChildFromBreeding`). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:17:45 +01:00
Ludovic Marie 8ec47d6419 Merge remote-tracking branch 'origin/main' into feat/server-mods-world 2026-10-26 19:14:31 +01:00
Ludovic Marie 5ecce8a7a3 Merge pull request #2142 from kacpherek/fix/mob-damage-difficulty-scaling
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.hurtServer` scales damage that `scalesWithDifficulty` from a living non-player: none on Peaceful, `min(d / 2 + 1, d)` on Easy, `d * 3 / 2` on Hard (so a creeper's blast scales, and TNT a player lit doesn't). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:14:06 +01:00
Ludovic Marie 10c9d8440c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 19:13:17 +01:00
Ludovic MarieandClaude Opus 5.5 e57faff4a3 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:10:55 +01:00
Ludovic Marie badceed27c Merge pull request #2188 from kacpherek/fix/loot-set-stew-effect
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SetStewEffectFunction` keeps the effect it rolled, with the duration times 20 unless the effect is instant. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:10:53 +01:00
Ludovic Marie 45159841bc Merge pull request #2316 from ludovic111/fix/campfire-not-a-container
🤖 Agent: Merging ahead of the queue. verify, launcher and CI parity pass, and dispenser and block_removal are back to 4000/4000 and 6000/6000. This turns main green again after #2015.
2026-10-26 19:10:13 +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
Ludovic Marie cfc55bb6c6 Merge pull request #2185 from kacpherek/fix/slime-block-step-slowdown
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SlimeBlock.stepOn` slows a walker that isn't sneaking when `|dy| < 0.1`, by `0.4 + |dy| * 0.2`. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:06:12 +01:00
Ludovic Marie 6af65a142b Merge pull request #2182 from kacpherek/fix/weather-command-without-cycle
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/weather` sets the weather whether or not `advance_weather` is on. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:00:56 +01:00
Ludovic Marie 60109e0d91 Merge pull request #2181 from kacpherek/fix/sleep-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerPlayer.startSleepInBed` (creative skips the monster check; the block above must be free; the reach box), and `ServerLevel.tick`'s morning skip with the weather reset. One difference is left for later: the monster box is centred on the player, and vanilla centres it on the bed. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:56:09 +01:00
Kacpherek d7ec7b6077 Shulker bullet split: record whether the shell was open at hit time 2026-10-26 18:55:45 +01:00
Ludovic Marie 1f88194b61 Merge pull request #2180 from kacpherek/fix/fire-burnout-difficulty-radius
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `FireBlock.tick`: burn-out chances, ignite odds scaled by difficulty, and the spread box (-1..1 sideways, -1..4 up). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:53:10 +01:00
Ludovic Marie 17fa57db3d Merge pull request #2179 from kacpherek/fix/lightning-fire-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LightningBolt.spawnFire` only on Normal and Hard and with fire spread on, as here. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:49:47 +01:00
Ludovic MarieandClaude Opus 5.5 dae701a3c7 Merge main into fix/armor-wear-environment-damage
Main's thorns (#2144) sit where this branch took out hurt_armor; the armor
now wears through the damage pipeline and main's thorns stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:49:18 +01:00
Kacpherek e2b1dc6691 Soul Speed: run its effects for each server-side player 2026-10-26 18:49:12 +01:00
Kacpherek e60cbe8d4d Keep whole nugget stacks and picked-up enchantments on piglins 2026-10-26 18:48:03 +01:00
Kacpherek a4407a4bcc Elytra: Unbreaking for remote players, keep wall hit events on the server copy 2026-10-26 18:47:34 +01:00
Ludovic Marie 0b98555009 Merge pull request #2189 from kacpherek/fix/dragon-crystal-heal-reach
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `EnderDragon.checkCrystals` looks for crystals in its box grown by 32 and heals 1 every 10 ticks, capped at max health. Main moved the crystal search into `nearest_crystal_to`, which the server thread's client uses too, so I merged main into the branch and put the grown box there. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:46:47 +01:00
Ludovic Marie d6a7f7f32c Merge pull request #2176 from kacpherek/fix/2161-shared-key-bindings
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `KeyMapping.click` and `set` act on every mapping bound to the key, as here, and your second commit stops a key from replaying a press when two mappings share it by default. #2207 fixes the same issue; I kept this one because it came first and changes less. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:46:14 +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
Ludovic Marie 2ca383f36d Merge pull request #2183 from kacpherek/fix/campfire-shovel-douse
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ShovelItem.useOn` puts out a lit campfire (`CampfireBlock.dowse`) with its sound and smoke. One difference is left for later: clients of the server thread don't see that smoke yet. Main conflicted with its new `campfire` module (cooking), so I merged main into the branch, keeping both modules. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:43:00 +01:00
Ludovic Marie 5812071a1b Merge pull request #2172 from rdbtCVS/fix/1525-forced-dimension-ticking
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerLevel.tick` keeps ticking a level that has forced chunks, even with no players and past the 300-tick empty timeout. `lan_e2e.py --server-thread --check forced-levels` passes. Main conflicted in lan_e2e.py, so I merged main into the branch, keeping both checks. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @rdbtCVS!
2026-10-26 18:42:08 +01:00
Ludovic MarieandClaude Opus 5.5 eb66e42e38 Merge feat/mod-recipe-pages into feat/mod-recipe-pages-game
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:39:55 +01:00
Ludovic MarieandClaude Opus 5.5 6a62ad67cc mods: set-recipes takes a row of tabs, and the tab event says which one the player picked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:39:46 +01:00
Ludovic Marie 1a43fa5859 Merge pull request #2206 from socksprox/fix/piston-moving-block-anim
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerLevel.runBlockEvents` broadcasts each handled block event, and `ClientLevel.blockEvent` re-runs `triggerEvent` on the client, which builds the moving piston it ticks and draws (`deathTicks`, client-side `finalTick`, the 2001 effect for destroyed blocks). I pushed one fix with a test. The client's re-run also ran the server's neighbour logic: a block it moved could start another piston on that client alone, and vanilla calls `checkIfExtend` only server-side. Its `playSound(null, ...)` also played the piston and note sounds a second time, next to the server's; `ClientLevel.playSeededSound` plays only when the excluded player is the local one. In game on the server thread, a piston that pushes a redstone block under a second piston leaves both clients' blocks as the server has them. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @socksprox!
2026-10-26 18:39:19 +01:00
Ludovic Marie 212775a11f Merge pull request #2144 from kacpherek/fix/thorns-hits-attacker
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: Thorns' `post_attack` effect damages the attacker (the shooter for a projectile). Main conflicted in `lan_pvp.rs` because it already had `Strike.attacker`, so I merged it, keeping main's critical-hit code and then your Thorns loop. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:37:46 +01:00
Ludovic MarieandClaude Opus 5.5 a4764f5b0d Merge origin/main into refactor/horizon-command
Brings #2212 (namespaced furnace RecipesUsed), which save_roundtrip needs:
CI's parity checks out this branch's head, not a merge with main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:36:14 +01:00
Ludovic Marie edd7f712b6 Merge pull request #2293 from ludovic111/ci/thinkpad-off
Tools: the ThinkPad no longer runs harnesses or builds; playhorizon is the Linux host
2026-10-26 18:35:33 +01:00
Ludovic Marie cbfb8845d2 Merge pull request #2191 from kacpherek/fix/hoglin-repellents
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `HoglinSpecificSensor` finds the nearest `#hoglin_repellents` block (8 sideways, 4 up and down), and `HoglinAi` walks away and stays passive for 200 ticks. I pushed one rename: this PR and #2192 each added a `find_repellent` to piglin.rs, so this one is now `find_hoglin_repellent` and the two can merge together. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:34:59 +01:00
Ludovic Marie 0b1ed3cdf5 Merge pull request #2175 from rdbtCVS/t3/fix-breaking-crack-jitter
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked: the crack overlay keeps its UVs aligned from one stage to the next. Its ignored GPU test passes here (macOS, Metal). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @rdbtCVS!
2026-10-26 18:33:51 +01:00
Ludovic MarieandClaude Opus 5.5 1825b3991c Campfires are no Container for droppers and hoppers
Since campfires got their block entity (#2015), the container lookup took
it for a zero-slot container: a dropper facing one kept its item instead of
dropping it, as vanilla's HopperBlockEntity.getContainerAt finds no Container
there. The dispense fixture also filled campfires through the generic slots,
which a campfire doesn't have, so block_removal saw them empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:30:56 +01: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
Ludovic MarieandClaude Opus 5.5 db13c68585 mods: recipes.place and recipes.give, a recipe viewer's actions
recipes.place(book-id, all) is the recipe book's click: in the open
inventory's or crafting table's grid it sends ServerboundPlaceRecipePacket
on a server, or places it in this world, refused as handlePlaceRecipe
refuses it (no crafting screen, a recipe the player's book doesn't hold, an
impossible one); a recipe missing items shows as a ghost. recipes.give(stack)
gives a creative player the stack as the creative inventory does; a server
hears it as SetCreativeModeSlot through the inventory's sync. Both answer at
once from the tick's snapshot (placing, creative, known) and take at most 36
calls a hook call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:28:26 +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
Ludovic Marie 9422f698ee Merge pull request #2107 from kacpherek/fix/1959-pack-reload-surface
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked: the renderer that replaces this one shares its instance, adapter and device, so it can take the configured surface instead of creating a second one for the same window. Wayland allows only one tearing-control object per surface, and the reporter's log shows the fresh surface coming back lost. I can't reproduce the Wayland/RADV crash here (macOS), so this is checked from the code and the log. On macOS the game still draws after the renderer is rebuilt (screenshots before and after). Fixes #1959. Main's HDR output conflicted in recreate_surface, so I merged main into the branch: the reused surface now goes through main's Renderer::configure too. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 18:21:17 +01: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
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
Ludovic MarieandClaude Opus 5.5 64f4e9b6ff parity_hosts: playhorizon skips mob_ai, lan_integration, world_reopen (failed in its trial)
Full trial (run 40341431094): 342/348 in 96.9 min; save_roundtrip,
remote_fireball and server_player_sculk passed on a rerun with vscreen and
fewer jobs; these three did not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:12:19 +01:00
Ludovic MarieandClaude Opus 5.5 61ff050927 mods: drop the sandbox test's unused LEVEL import
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:11:56 +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
Ludovic Marie 800b91567a Merge pull request #2303 from ludovic111/fix/setblock-loot-table
🤖 Agent: Merged by Ludovic's AI agent (Claude). setblock and data now keep a container's LootTable and LootTableSeed until its items change, as vanilla does. command_exec matches the 26.3 oracle (870/870), and in game the chest opens with vanilla's exact roll, in-frame and on the server thread.
2026-10-26 18:11:16 +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
Ludovic MarieandClaude Opus 5.5 ad557fef47 game: a mod's recipe pages in the item panel
browser.set-recipes replaces the panel's items with the mod's recipes,
laid out with the game's slot, furnace arrow and flame sprites and small
buttons: a crafting grid, one input over a flame, one input, three inputs
or two, then an arrow and the result, a note under it and the action
button beside the result (under it when the panel is narrow). Slots cycle
through their stacks once a second. As many recipes as fit the panel's
height make a page, with the recipe book's arrows; a title row holds a
Back button and the title. The same title sent again keeps the page.

Clicks on the slots, the action button, the arrows and Back reach the mod;
tooltips and the mods' keys work on the recipes' stacks as on the items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:03:42 +01:00
Ludovic MarieandClaude Opus 5.5 fc9305bb15 mods: browser.set-recipes, recipes in the item panel's place
A mod gives the recipes to show (a layout: crafting grid, cooking, single
input, smithing or brewing; the slots, each cycling through its stacks;
the results; a note; and an optional action button, enabled or greyed)
under a title; an empty list shows the items again. The game pages them
and sends the slot-clicked, action, page and back events.

At most 256 recipes, 9 slots each and 64 stacks a slot. Same level as the
rest of browser (5): none of it has shipped yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:03:42 +01:00
Ludovic MarieandClaude Opus 5.5 fbbed63f04 setblock and data keep a container's loot table
A command's tag went through decode_block_entity, which has nowhere to put
LootTable, so `setblock ... chest{LootTable:...}` made an empty chest
(`data get` showed `Items: []`). An empty container given a LootTable is
now rolled as a loaded chunk's are, and the roll is kept with its chunk
(RolledLoot), so it saves, and `data get` shows, LootTable and
LootTableSeed until its items change (tryLoadLootTable / trySaveLootTable).
`data merge` and `data modify` go through the same path.

command_exec: setblock a seeded loot chest, then data get it whole and its
LootTableSeed (870/870 match the 26.3 oracle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:02:34 +01:00
Ludovic Marie 21a89c2e7f Merge pull request #2046 from ESHAYAT102/fix/remote-squid-animation
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The client branch of Squid.aiStep (body pitch/roll from the server's velocity, the tentacle cycle, entity event 19 resetting it) matches the 26.3 bytecode. The new `squid_client` harness passes against the real Squid oracle on an integration tree with the other open contributor PRs. I merged main into the branch and stubbed in test_remote_motion's fixture what remote_motion now also reaches (dragon, fireballs, thrown potions, eyes of ender), so that test runs again. Fixes #1684 is right. Thanks @ESHAYAT102!
2026-10-26 18:00:12 +01:00
Ludovic Marie e24ed85036 Merge main into fix/1525-forced-dimension-ticking 2026-10-26 17:59:02 +01:00
Ludovic MarieandClaude Opus 5.5 06a45d86a1 Merge main into fix/1959-pack-reload-surface
Main's HDR output configures the surface through Renderer::configure; the
reused spare surface goes through it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:58:15 +01:00
Ludovic Marie 64fab2cc83 Merge pull request #2060 from kacpherek/fix/1953-creative-menu-fps
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Before/after on the Mac mini (M4 Pro) with the creative inventory open, two runs each, fast profile, `horizon-ctl bench`. Main: CPU frame 0.81 to 0.91 ms on the building/combat tabs and 1.32 to 1.47 ms while searching (664 to 742 fps). This PR: 0.44 to 0.49 ms on building/combat and 0.49 ms while searching (1122 to 1125 fps). The panel pixels match main. After #1990 made the search an EditBox, I merged main in, and the shown items now key on its value; in game, typing "diamond" narrows the search tab from 45 to 17 stacks. `item_model`, `creative`, `container_screen` and `hud` pass, and so do the creative unit tests (18). Fixes #1953 is right. Thanks @kacpherek!
2026-10-26 17:57:51 +01:00
Ludovic Marie 57f5e1fb8e Merge pull request #2299 from ludovic111/fix/macos-python-tests
🤖 Agent: Merged by Ludovic's AI agent (Claude). Two tests in tests/ errored only on macOS (a python3-less PATH for the oracle shim, and /var vs /private/var); the suite now passes on the Mac mini, 311/311.
2026-10-26 17:53:36 +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
Ludovic MarieandClaude Opus 5.5 763fc284ed Merge feat/mod-item-panel-api into feat/mod-item-panel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:51:36 +01:00
Ludovic MarieandClaude Opus 5.5 b8da45f5ef mods: browser's page event names the first recipe shown, set-recipes' indexes are its list's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:51:32 +01:00
Ludovic MarieandClaude Opus 5.5 823941756f block_removal: print a merged orb once per count on both sides
Whether two orbs merge (ExperienceOrb.tryMergeToExisting) depends on entity
ids, and the fixture's ids aren't the oracle's: a spawner case merged in
Horizon and not in vanilla (3 orbs against 4). Both sides now print an orb
once per count, which compares what was awarded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:46:53 +01:00
Ludovic MarieandClaude Opus 5.5 487a461a8a game: the item panel of a mod's browser beside container screens
The panel sits right of the inventory or container window, from the top of
the screen to a search box at its foot, in up to 12 columns of the game's
own item slots, with the recipe book's page arrows. It shows on the
survival inventory and every container screen (not the creative one) while
a running mod has items in it, and hides when there is no room for two
columns. Clicks on it never throw the carried stack; the carried stack and
tooltips are drawn again above it.

The mod hears clicks on items, the search text as it changes, and its own
keys pressed with the pointer on an item, the panel's or a slot's. The
search box takes every key but Escape while focused, as the recipe book's.
Each set-items is turned into stacks once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:46:29 +01:00
Ludovic MarieandClaude Opus 5.5 aa839fa99a mods: browser, a recipe viewer's item panel (API level 5)
A new interface under capability ui. browser.set-items lists the items of
a panel the game shows right of the inventory and container screens (an
empty list hides it); browser.poll returns what the player did since the
last poll: a click on an item, the search text, or one of the mod's own
keys pressed over an item (a slot's or the panel's), with that stack. The
slot-clicked, action, page and back events are there now for the recipe
pages to come, since adding variant cases later would break mods.

At most 8192 items and 256 waiting events. The game side is the next PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:46:29 +01:00
Ludovic MarieandClaude Opus 5.5 d01a621fec tests: two parity-runner tests pass on macOS
OracleReuseTests ran the shim through `#!/usr/bin/env python3` with a PATH
whose Python folder is Homebrew's, which names it only python3.13: the
shim wasn't found (127). The test now gives that PATH a python3 of its own.

test_report_rejects_sources_changed_during_a_run compared a resolved source
path with an unresolved temp root, and macOS's /var is /private/var.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:46:06 +01:00
Ludovic MarieandClaude Opus 5.5 0f22a274fa test_remote_motion: stub what remote_motion reaches on main
remote_motion now also hands motion to the dragon, its fireballs, thrown
potions and eyes of ender; the extracted fixture needs those to compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:44:05 +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
Ludovic Marie a42857ab48 Merge pull request #2050 from rdbtCVS/rdbt/fix-1527-forced-chunk-save
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Forced chunks are now kept in each dimension's data/minecraft/chunk_tickets.dat as 26.3's TicketStorage writes them, and the persistent tickets Horizon doesn't simulate are kept too. A world with /forceload areas round-trips with vanilla 26.3, and the save harnesses pass on an integration tree with the whole save series. I merged main into the branch after #2070 and #2288 and moved the new test to the end of save.rs's tests, so the other save PRs don't collide with it (save:: 27/27). Fixes #1527 is right. Thanks @rdbtCVS!
2026-10-26 17:34:12 +01:00
Ludovic MarieandClaude Opus 5.5 eee7253b1b Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:33:02 +01:00
Ludovic Marie 5963e6cc01 Merge pull request #2294 from ludovic111/fix/recipe-command-targets
🤖 Agent: Merged by Ludovic's AI agent (Claude). /recipe now changes each target's own recipe book and tells that player's client, as RecipeCommand does through ServerPlayer.awardRecipes/resetRecipes. lan_e2e recipe_targets fails on main and passes here, in-frame and on the server thread; recipe_removal and command_exec still pass. Thanks to platform-2 for finding it.
2026-10-26 17:31:33 +01:00
Ludovic Marie d26252bff5 Merge pull request #2291 from ludovic111/carry/2074-poi-files
🤖 Agent: Merged by Ludovic's AI agent (Claude). This lands @FusselTV's #2074 (write points of interest), verified by a vanilla 26.3 round trip; the save harnesses pass. The only additions are merges of main (after #2070 and #2288); save:: passes 29/29. Thanks @FusselTV!
2026-10-26 17:26:38 +01:00
Ludovic MarieandClaude Opus 5.5 f69d23019b Merge main (the probe list keeps squid_client_probe beside the new probes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:26:12 +01:00
Ludovic MarieandClaude Opus 5.5 2fc2ee1e13 Merge main (creative.rs: the search is an EditBox since #1990; the shown items key on its value)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:23:53 +01:00
Ludovic Marie abd2d9f3fe Merge pull request #2283 from ludovic111/ci/launcher-check
🤖 Agent: Merging. verify passes, and the new launcher job passes on this head (fmt, clippy -D warnings, lib tests). On the earlier head it had caught main's E0061 before #2281. The Python tests show the same 2 environment errors as main.
2026-10-26 17:23:37 +01:00
Ludovic MarieandClaude Opus 5.5 b8e90da1c2 Merge main (save.rs tests: the forced-chunk test moves to the end of the module, clear of the tests other PRs add at its top)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:22:07 +01:00
Ludovic MarieandClaude Opus 5.5 d8e68148be /recipe gives and takes each target's own recipes
cmd_recipe changed self.level.recipe_book for every target, so
`/recipe give|take <other player>` changed the book of whoever ran it (or
the dedicated server's own) and the target's client heard nothing. Each
target now runs with its state swapped in (for_player), as
ServerPlayer.awardRecipes/resetRecipes do on the player's own
ServerRecipeBook: the guest's book changes and its client gets
ClientboundRecipeBookAdd/RemovePacket.

lan_e2e recipe_targets: the host gives and takes the guest's recipes and,
on the server thread, the guest gives the host one; each open book must
show exactly its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:18:47 +01:00
Ludovic MarieandClaude Opus 5.5 7e5fa6f91b Merge main (save.rs tests: the POI tests beside #2073's loot test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:18:19 +01:00
Ludovic Marie e0423bba77 Merge remote-tracking branch 'origin/main' into ci/launcher-check
# Conflicts:
#	tools/ci/rust_changes.py
2026-10-26 17:15:21 +01:00
Ludovic Marie 04cdf22527 Merge pull request #2288 from ludovic111/carry/2073-keep-loot-tables
🤖 Agent: Merged by Ludovic's AI agent (Claude). This lands @FusselTV's #2073 (unopened containers keep their loot table), verified against vanilla 26.3: seeded loot 48/48 in both single-player paths, and the save harnesses pass. The only addition is a merge of main after #2070. Thanks @FusselTV!
2026-10-26 17:15:02 +01:00
Ludovic MarieandClaude Opus 5.5 94e8b184bc Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:14:48 +01:00
Ludovic MarieandClaude Opus 5.5 9f1784dc0c rust_changes: append the repository root to sys.path instead of prepending tools/
Prepending tools/ made unittest discovery import tools/test_decoration_parity.py
in place of tests/test_decoration_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:14:46 +01:00
Ludovic MarieandClaude Opus 5.5 9aba251de5 Merge #2288 (#2073 with main merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:14:34 +01:00
Ludovic MarieandClaude Opus 5.5 3ae2aad690 Merge main (save.rs: SaveDir's shape beside the forced chunks; both tests kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 0bd6a576a9 Tools: the ThinkPad no longer runs harnesses or builds; playhorizon is the Linux host
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:12:10 +01:00
Ludovic Marie 5f1c25c226 Merge pull request #2171 from ludovic111/feat/mod-run-quiet
🤖 Agent: merged main to resolve a conflict in game/remote.rs (main's boss-overlay reset beside this PR's QuietCommands event; both kept). Clippy with mods on is clean. Requeued at the new head.
2026-10-26 17:11:50 +01:00
Ludovic MarieandClaude Opus 5.5 7f6d556e68 docs: Lua mods' page goes to horizon-site with the rest of website/
docs/mods.md keeps the reference, plus what a capability missing from the features does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:09:10 +01:00
Ludovic Marie f7170d6b9f Merge pull request #2045 from ESHAYAT102/fix/creative-tool-block-breaking
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Item.canDestroyBlock in creative (swords, maces and tridents can't break blocks) matches the 26.3 bytecode, on the player's prediction and on the server. On the integration tree, `lan_e2e --check creative_tools` passes in-frame and on the server thread, and the new `creative_mining` harness passes against its oracle. I merged main into the branch twice; the second time, lan_e2e.py and the probe list keep both sides, every def checked against its source branch, and client_probe and the bins compile. Fixes #2035 is right. Thanks @ESHAYAT102!
2026-10-26 17:09:09 +01:00
Ludovic MarieandClaude Opus 5.5 d33f547bf7 Merge origin/main into feat/mods-lua
website/ moved to its own repo (#2286): this branch's changes to make-a-mod.md and nav.json go there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:55 +01:00
Ludovic MarieandClaude Opus 5.5 43ae7a0974 Merge main (save.rs: LevelShape beside the POI encoder; both sets of tests kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:19 +01:00
Ludovic MarieandClaude Opus 5.5 96984300d3 mods: the UI guest's level is 3 now that world is level 4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:07:20 +01:00
Ludovic Marie 91295ab88c Merge pull request #2109 from kacpherek/fix/2083-remote-effect-clouds
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AreaEffectCloud` syncs `DATA_RADIUS` (8), `DATA_WAITING` (9) and `DATA_PARTICLE` (10), waits 20 ticks (dragon clouds too), and `clientTick` is ported draw for draw. `DragonFireball`'s trail is `dragon_breath`. One fix pushed: `AbstractHurtingProjectile.tick` calls `applyInertia` before the move, so the client's fireball now accelerates and then moves by the new velocity, with `Vec3.normalize`'s zero for a tiny vector. Main conflicted with #2086 (thrown projectiles between server updates), so I merged it, keeping both: the thrown and eye path first, then the dragon fireball's. `lan_e2e.py --check particles` and `objects` pass in both modes. Part of #2083, as it says. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 17:06:31 +01:00
Ludovic Marie bb81cb4586 Merge pull request #2078 from RoyalPixelsDE/fix/structure-starts
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Chunks now write the structure starts and references they hold, so vanilla keeps the structures (and their spawns) after the round trip. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 17:03:35 +01:00
Ludovic MarieandClaude Opus 5.5 bf39e5d49e Merge main (save.rs: keep both new tests; the loot test encodes with LevelShape::OVERWORLD after #2070)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:01:49 +01:00
Ludovic Marie 8b792957f7 Merge pull request #2286 from ludovic111/chore/site-repo-split
🤖 Agent: merging. playhorizon.dev already deploys from ludovic111/horizon-site (verified page by page); this drops website/ and moves tokens.css, roadmap.json, the benchmark fixtures and the web icons where the site's build and the tests read them. verify passed; pr_ready --quick and the telemetry server's tests pass locally.
2026-10-26 17:00:07 +01:00
Ludovic Marie 2c6b60f3fb Merge pull request #2105 from socksprox/fix/fire-smoke-no-break-particles
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BaseFireBlock.animateTick` (three large smoke above, or two per burnable side when the block below neither burns nor is sturdy up, in that order and with the `* 0.1F` float edge), `SoulFireBlock.canBurn` always true, and `Level.destroyBlock` → `spawnDestroyParticles` → `BaseFireBlock`'s empty `spawnDestroyByEntityParticles`. `fire.igniteOdds` is in block-behaviours.json at the top level, as read. I added one rule: `FireBlock.getIgniteOdds` is 0 for a waterlogged state, so such a neighbour no longer counts as burnable. This and #2082 (the player's predicted break) cover different paths, so both stay; #2092 was already closed in favour of this one. In game, fire and soul fire show their large smoke above them. Thanks @socksprox, and @kacpherek for #2092! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:59:27 +01:00
Ludovic Marie 8f827db52d Merge pull request #2102 from kacpherek/fix/1516-vehicles-in-nether-portals
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `NetherPortalBlock.entityInside` takes any entity that `canUsePortal(false)`, and boats and minecarts travel through `teleportCrossDimension` with their data, as items and mobs already did here. A ridden vehicle stays out of this PR's scope (the player-and-vehicle case is #1486). `lan_e2e.py --check vehicles`, `boats` and `minecarts` pass in both modes. Fixes #1516. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:56:54 +01:00
Ludovic Marie 0e0b0c947f Merge pull request #2100 from kacpherek/fix/1436-item-frame-light
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ItemFrameRenderer.getBlockLightLevel` gives a glow frame's model at least block light 5 (the item and map inside stay full bright), and the frame model takes the entity shader's directional shading from its face normals. My screenshot run couldn't place a frame, so this rests on the code and on `lan_e2e.py --check frames`, which passes in both modes. Fixes #1436. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:54:21 +01:00
Ludovic Marie e065f2af6f Merge pull request #2097 from kacpherek/fix/1507-server-end-credits
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `EndPortalBlock.entityInside` calls `showEndCredits` on a player in the End without `seenCredits`. That sends `WIN_GAME` with 0.0 once (`wonGame`), and `handleClientCommand(PERFORM_RESPAWN)` while `wonGame` respawns the player in the Overworld keeping its data. `seenCredits` round-trips through playerdata. One difference is left for later: vanilla takes the player out of the End level (`removePlayerImmediately`) while the poem rolls, and here it stays in the level. `lan_e2e.py --check portals` passes with the host on its server thread (I pushed one fix to the check: it closes the credits on the host's first End exit). Fixes #1507. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:51:57 +01:00
Ludovic MarieandClaude Opus 5.5 8bb27b44ca Merge origin/feat/mod-run-quiet into feat/server-mods-world; world at API level 4
#2171 takes level 3 (command.run-quiet), so the world interface's functions
take level 4. The workspace members keep main's horizon-hdr and this branch's
beacons example.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:51:29 +01:00
Ludovic Marie 8ccf20d909 Merge pull request #2077 from RoyalPixelsDE/fix/structure-starts-3-legacy
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The remaining legacy structure pieces now save their vanilla fields. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:51:18 +01:00
Ludovic Marie c5f9e3404c Merge remote-tracking branch 'origin/main' into feat/mod-run-quiet
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-26 16:49:16 +01:00
Ludovic Marie c3f5e9f79c Merge remote-tracking branch 'origin/feat/mod-run-quiet' into feat/server-mods-world
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-26 16:49:04 +01:00
Ludovic Marie 7ace1e9e8f Merge pull request #2076 from RoyalPixelsDE/fix/structure-starts-2-templates
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Template pieces now save as vanilla's TemplateStructurePiece does. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:48:38 +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
Ludovic Marie ebf2132885 Merge pull request #2103 from rdbtCVS/fix/1948-merchant-cost-components
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ItemCost.STREAM_CODEC` is item, count, then `DataComponentExactPredicate.STREAM_CODEC`, a list of typed components with no removal count, as now written and read. Its verify failed only on rustfmt (item_sync.rs), which I pushed. Main conflicted in item_sync.rs's tests (the new bundle test), so I merged it, keeping both tests. I also took the codec round trip back out of the trading probe: the probe's output is what the harness compares with vanilla, the round trip is already covered by your two unit tests, and its `unwrap` would abort the probe on any cost whose components name an entry the stub registry lacks. `lan_e2e.py --check hero-trading` passes in both modes. Fixes #1948. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:44:53 +01:00
Ludovic MarieandClaude Opus 5.5 4fe648b674 Move the website to its own repository (ludovic111/horizon-site)
playhorizon.dev now deploys from the private ludovic111/horizon-site, which holds website/'s history. The files
its build still reads from here move out of website/:

- the design tokens to crates/horizon-theme/tokens.css (xtask tokens writes them, tokens_css_is_up_to_date checks them);
- the roadmap's JSON to roadmap/roadmap.json (tools/roadmap.py build);
- the benchmark fixtures to crates/horizon-telemetry-server/tests/fixtures/benchmarks/ (the site keeps a copy);
- the web icons to docs/media/brand/web/ (xtask icons).

pr_ready loses its website area and the tokens special case; rust_changes, the roadmap's links and every doc
that pointed into website/ follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:43:32 +01:00
Ludovic MarieandClaude Opus 5.5 4471e59132 Load the loot tables before a world's first chunks are read
A loaded container with a loot table is rolled as its chunk is decoded, but
the loot tables were loaded lazily by the first level tick. The spawn chunks
are decoded while the world opens, so a loot chest in them came out empty and
lost its table on the next save. WorldRequest::prepare, which every world
opens through (in-frame, server thread, dedicated server), now loads them
first.

spawn_loot: vanilla writes a flat world with a seeded loot chest by the
spawn and rolls it; Horizon opens it in a fresh process, in-frame and on the
server thread, and must hold the same stacks in the same slots. Without the
fix both modes hold nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:41:23 +01:00
Ludovic MarieandClaude Opus 5.5 402b8dc01e Merge main (lan_e2e.py and the probe list keep both checks and probes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:39:28 +01:00
Ludovic MarieandClaude Opus 5.5 aa8a78e1b7 Merge main into fix/daylight-detector
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:35:36 +01:00
Ludovic MarieandClaude Opus 5.5 0c8e8037df Merge main into fix/campfire-shovel-douse
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:35:19 +01:00
Ludovic MarieandClaude Opus 5.5 dbbe3a45d9 CI: check the launcher's workspace on every push to main and on pull requests that touch it
verify's cargo runs never build launcher/ (its own workspace), so #2059 and the
registry ratings commit each passed alone and left main's launcher failing to
compile (E0061). The new launcher job runs its rustfmt, clippy -D warnings and lib
tests when a pull request changes the launcher, a crate it builds or the root
manifests, and on every push to main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:29:09 +01:00
Ludovic MarieandClaude Opus 5.5 7ac90ff0b8 Merge main into fix/1482-evoker-fangs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:28:40 +01:00
Ludovic MarieandClaude Opus 5.5 8df89ca4d6 Merge main into fix/dragon-crystal-heal-reach
Main moved the crystal search into nearest_crystal_to, which the server
thread's client uses too; the dragon's grown box now lives there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:28:09 +01:00
Ludovic MarieandClaude Opus 5.5 81c8e0e815 Merge main into fix/1525-forced-dimension-ticking
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:27:56 +01:00
Ludovic MarieandClaude Opus 5.5 4b89d0cd85 Merge main into fix/2083-remote-effect-clouds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:24:55 +01:00
Ludovic MarieandClaude Opus 5.5 37825b750a Piglin AI: name the piglin repellent finder for its mob
#2191 and #2192 each add a `find_repellent` to piglin.rs, one for
hoglins and one for piglins; merged together they would collide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:23:06 +01:00
Ludovic MarieandClaude Opus 5.5 68aa70108f Piglin AI: name the hoglin repellent finder for its mob
#2191 and #2192 each add a `find_repellent` to piglin.rs, one for
hoglins and one for piglins; merged together they would collide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:23:05 +01:00
Ludovic MarieandClaude Opus 5.5 96afc48ce2 Merge main into fix/1948-merchant-cost-components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:18:40 +01:00
Kacpherek 6f4c2cfbbc Count the hurt tilt down while the player is dead or riding 2026-10-26 16:16:19 +01:00
Ludovic MarieandClaude Opus 5.5 bf2c867966 Zombies: roll canBreakDoors when the game spawns them
Zombie.finalizeSpawn calls setCanBreakDoors(nextFloat() < special * 0.1)
after the baby roll; the game's spawn finalize never rolled it, so only
the finalize probe's zombies could break doors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:15:39 +01:00
Ludovic MarieandClaude Opus 5.5 2394d6e7dc Pistons: a client's replay starts no piston and plays no sound
A client's copy of a block event (`client_block_event`) ran the server's
neighbour logic: a block it moved could start another piston on the client
alone (`PistonBaseBlock.checkIfExtend` is only called server-side), and the
re-run's `playSound(null, ...)` played the piston and note sounds a second
time next to the server's (`ClientLevel.playSeededSound` plays only for the
local player as the excluded one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:14:21 +01:00
Ludovic MarieandClaude Opus 5.5 409a828128 mods: capability recipes (API level 4), the game's items and recipes for recipe viewers
recipes.items() lists every item in registry order, with its name in the
player's language and its tags. recipes.all() lists every recipe: crafting
and furnace ones from the recipe book's own entries (vanilla's displays and
display ids), then campfire, stonecutting, smithing and brewing (display
only). Stacks point into the item table by index. recipes.known() gives the
book ids the player's recipe book holds, from the client book in every mode,
so on a server it follows the server's book.

The game builds the tables once, the first time a running mod reads them.
Experimental, new functions only. Smithing::Transform now keeps its recipe
id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:11:29 +01:00
Ludovic Marie 2e7283d01c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 16:10:40 +01:00
Ludovic MarieandClaude Opus 5.5 625c4c559e Merge main into the creative tool mining fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:10:15 +01:00
Kacpherek e157c5a0a5 Tick conduits on the server without a renderer 2026-10-26 16:07:31 +01:00
Kacpherek 92e59024f2 Give merged orbs their value once per count for LAN guests and dedicated players 2026-10-26 16:07:23 +01:00
Kacpherek c75854a968 Send mob door cracks to nearby players 2026-10-26 16:07:11 +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
Ludovic MarieandClaude Opus 5.5 864ba24c3d Merge main into fix/thorns-hits-attacker
Main's critical-hit change added the same Strike.attacker field; keep
one, then the crit particles before the victim's thorns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:56:14 +01:00
Ludovic MarieandClaude Opus 5.5 608a6d9bee Turtles: a fixed home from spawn or load, one breeding orb
Turtle.finalizeSpawn sets homePos to where it spawns, and loading falls
back to the block it stands on; the home no longer moves with a turtle
that had none, which let it lay eggs anywhere. TurtleBreedGoal.breed adds
one ExperienceOrb of 1 to 7 drawn from the mother's random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:50:47 +01:00
Ludovic MarieandClaude Opus 5.5 75f7029d01 Squid ink: draw the squirt's voice pitch even when silent
Mob.makeSound calls getVoicePitch before Entity.playSound checks
isSilent, so the squid's random advances either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:48:37 +01:00
Ludovic MarieandClaude Opus 5.5 1d51f9d873 Soul Speed: draw the random chances in vanilla's order
Both random_chance terms come first in their all_of conditions, so the
level random is drawn before the block and movement checks: the boots'
4% on every location change, and the sound's 35% on every tick the
boots are worn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:48:30 +01:00
Ludovic MarieandClaude Opus 5.5 7d8b6f3b90 Composter: keep addLayer's doc on composter_add_layer
The extracted composter_extract_produce took the doc comment of the
function below it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:47:37 +01:00
Ludovic Marie aed8b571c4 Merge remote-tracking branch 'origin/main' into feat/server-mods-world 2026-10-26 15:46:47 +01:00
rdbtCVSandOpenCode GPT-6.1 Sol 5eef2bea0c Clean up evoker summons after fang integration check
Remove the test arena's surviving vexes so later checks receive no stray attacks.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 14:45:59 +00:00
Kacpherek 545370b023 Key binds: press a shared default key once 2026-10-26 15:41:11 +01:00
socksproxandDevin 48811b12f1 Pistons animate their moves on the clients, as Java's do
A remote client (single player's window on its server thread, a LAN
guest, a dedicated server's client) never ran the client side of a
piston's move: the block event reached block_entity_event, which has no
piston ticker, so moving_piston blocks arrived with no moving block
entity and pushed blocks teleported (#1704).

Vanilla's runBlockEvents broadcasts a handled event to the clients, whose
own level re-runs triggerEvent (PistonBaseBlock.moveBlocks) to build the
PistonMovingBlockEntity it then ticks and renders interpolated. Do the
same:

- run_block_events emits a BlockEntityEvent for a handled event; the LAN
  and integrated hubs send it as ClientboundBlockEventPacket, before the
  tick's block changes, as vanilla's broadcast precedes
  ChunkHolder.broadcastChanges.
- The remote client re-runs triggerEvent for pistons, note blocks and
  bells on its own level (dropResources stays server-side, as
  popResource checks !isClientSide), and ticks the resulting moving
  pistons each client tick so they animate and collide between the
  server's states. Block events a client-side move queues run right
  away, as ClientLevel.blockEvent does.

Note blocks and bell rings reach the guests through the same packet,
which they did not get before.

Co-Authored-By: Devin <[email protected]>
2026-10-26 15:37:36 +01:00
Ludovic MarieandClaude Opus 5.5 7fbfbb5e2b Shulker bullet split: draw the failure chance from the level's random
Shulker.hitByShulkerBullet tests level().getRandom().nextFloat() against
the crowding chance, not the shulker's own random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:27:12 +01:00
Ludovic MarieandClaude Opus 5.5 6bdf5fe368 Sweep: hit mobs within 3 blocks in creative too
Player.doSweepAttack keeps nearby entities with distanceToSqr < 9.0; it
doesn't use the entity interaction range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:26:32 +01:00
Kacpherek 7d5a052261 Target blocks react to projectile hits
A target block never changed its power when something hit it. Every
projectile that hits a block now asks the block how to react, as vanilla
does: a target block takes its power from where the projectile landed
(1 to 15 by the distance from the face centre) and resets after 20
ticks for an arrow or 8 for any other projectile. A player's hit also
counts the target_hit statistic.
2026-10-26 15:02:57 +01:00
Kacpherek 84b621096a Satisfy clippy 2026-10-26 15:02:46 +01:00
Kacpherek f0d2a2ae74 Satisfy clippy 2026-10-26 15:02:43 +01:00
Kacpherek 0c22d75a2e Satisfy clippy 2026-10-26 15:02:41 +01:00
Kacpherek 4abf5c0ea2 Play no place sound after using an empty hand or a non-block item on a block 2026-10-26 14:58:57 +01:00
rdbtCVSandOpenCode GPT-6.1 Sol 02897ac731 Merge main into fix/1482-evoker-fangs
Preserve both fang and sonic-wave integration checks.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 13:54:53 +00:00
rdbtCVSandCodex GPT-6.1 Sol 6e0a49029f Keep block-breaking cracks aligned between stages
Restore integer atlas origins and sample local texel centers before nearest filtering. Cover the packed stage origins with a GPU sampling regression.

Fixes #1882.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-10-26 13:39:51 +00:00
Kacpherek bd59cdc25c Let turtles lay eggs that hatch into baby turtles
A bred turtle now carries an egg instead of the baby appearing at once.
She walks home, digs into sand for a while and lays a turtle egg there.
A hatching egg gives one baby turtle per egg with the egg as its home,
and a baby that grows up drops a scute.
2026-10-26 14:39:16 +01:00
Kacpherek 4ae514d0ae Throw leftover items from the eyes, and creative players keep their stack on an armor stand 2026-10-26 14:36:10 +01:00
Kacpherek 628ff01a44 Only zombies that break doors and raiding vindicators get through doors
Mobs no longer open or break doors on their own except villagers and copper
golems. Zombies that rolled canBreakDoors break doors on hard, and
vindicators break doors on normal and hard and open them during a raid, as
BreakDoorGoal, VindicatorBreakDoorGoal and RaiderOpenDoorGoal do. A zombie's
CanBreakDoors value is now saved and loaded.
2026-10-26 14:35:52 +01:00
Kacpherek c7bacfb293 Water buckets pick up fish and tadpoles, keeping their data 2026-10-26 14:35:50 +01:00
Kacpherek ee19d2c00c Experience orbs: wider pickup area, merging, and lava bounce 2026-10-26 14:33:48 +01:00
Kacpherek f24ca405a8 Piglins walk away from soul fire, soul torches, soul lanterns and lit soul campfires
Piglins now sense the nearest block from the piglin repellents tag within 8 blocks sideways and 4 up or down (a soul campfire only while lit). When one is closer than 8 blocks and the piglin is idle with nowhere to go, it walks away from it.
2026-10-26 14:33:25 +01:00
Kacpherek 091a98656e Dragon: find healing crystals in its grown box and cap healing at max
The search now uses the dragon's 16 by 8 box grown by 32 blocks like
EnderDragon.checkCrystals, and healing no longer goes above max health.
2026-10-26 14:32:46 +01:00
Kacpherek fd1d857f1b Hoglins keep away from warped fungus, portals and respawn anchors
Hoglins now sense the nearest block from the hoglin repellents tag within 8 blocks sideways and 4 up or down. While one is near they become passive for 200 ticks (the attack target is dropped and no new one is picked), walk away from it, and avoid picking random walk spots within 8 blocks of it. Being hit ends the passive state.
2026-10-26 14:32:02 +01:00
Kacpherek fd0cb00296 Axolotls dry out on land, squid squirt ink when a living entity hurts them 2026-10-26 14:31:58 +01:00
Kacpherek 3e78846975 Soul Speed: speed, no soul sand slowdown, boots wear, soul particles
The enchantment's location_changed effects now run for the player: the
movement_speed and movement_efficiency modifiers turn on over soul sand and
soul soil and stay on while airborne, the boots wear with a 4% chance per
block change, and walking on those blocks spawns soul particles and the
soul escape sound.
2026-10-26 14:31:28 +01:00
Kacpherek 8bfa7a5b7b Loot: suspicious stew keeps the effect set_stew_effect rolled
The function drew an effect and a duration but dropped them. The stack
now carries the effect and it is stored in suspicious_stew_effects.
2026-10-26 14:31:03 +01:00
rdbtCVSandCodex b1136ac287 Keep forced server levels ticking without players
Fixes #1525. Respect forced tickets when scheduling levels and bound playerless integrated-server ticks to their ticket sources.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-10-26 13:30:54 +00:00
Kacpherek 199178907c Shovels put out a lit campfire 2026-10-26 14:30:49 +01:00
Kacpherek 7abb092089 Wire fire burnout, difficulty and spread radius into the fire tick 2026-10-26 14:30:34 +01:00
Kacpherek fcfd425b94 Give the conduit its power and its attack
A conduit with enough prismarine around it now gives conduit power to
players in water or rain in range, hurts an enemy in water or rain when
it has 42 frame blocks, and plays its activate, deactivate and ambient
sounds, as ConduitBlockEntity.serverTick does. Its target also feeds the
client's attack particles.
2026-10-26 14:30:32 +01:00
Kacpherek d8fcebdf71 Match vanilla difficulty and survival rules for lightning fire 2026-10-26 14:30:31 +01:00
Kacpherek 551a9ee9c5 Make lightning strike the closest lightning rod 2026-10-26 14:30:29 +01:00
Ludovic MarieandClaude Opus 5.5 fb86430813 mods: command.run-quiet (API level 3), a batch of commands with nothing in the chat
A mod hands `run-quiet` a list of lines (at most 256 a call). In single
player they run as the player typing them would, with the player's source
made silent; on a Horizon server (it announces horizon:quiet_commands) they
go as one payload that the server runs the same way; on any other server
they fall back to chat commands, whose feedback shows. command.run is
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:30:23 +01:00
Kacpherek 6875c44bbe Piglins accept only gold ingots by hand, keep other loved items, pick up more
Right-clicking a piglin with any loved item used to consume it and the piglin threw it back. Now only piglin currency (gold ingots) is accepted from the hand, and anything else a piglin admires is worn if it can be or stored in its inventory, which saves, and drops when it dies. Piglins also pick up gold nuggets when they have room, food when they have not eaten lately, and gear that fits an empty slot.
2026-10-26 14:28:40 +01:00
Kacpherek bcdf769f8d Illagers do not attack baby villagers
AbstractIllager.canAttack refuses a baby villager or wandering trader.
Skip them when an illager picks a nearest target and drop them from an
illager's current target.
2026-10-26 14:28:20 +01:00
Kacpherek 54fe73854e Cactus destroys dropped items that touch it 2026-10-26 14:28:06 +01:00
Kacpherek 4aa7838d1b Shulker bullets make open shulkers teleport and duplicate
An open shulker hit by a shulker bullet teleports and may leave a new
shulker of its colour behind, like Shulker.hitByShulkerBullet. Levitation
from the bullet now needs the hit to have hurt the player.
2026-10-26 14:27:48 +01:00
Kacpherek 991406d0e0 Turtle helmet: Water Breathing while the eyes are out of the water 2026-10-26 14:27:10 +01:00
Kacpherek 0ba73d0a7e Match vanilla evoker, fangs and vex rules
Count vexes and look for blue sheep within a 16 block radius, spare only
illagers and their vexes with evoker fangs, and let vexes ignore raiders
(including ravagers) instead of other vexes.
2026-10-26 14:27:05 +01:00
Kacpherek 05fd9c3795 Chests with a block or a sitting cat on top no longer open 2026-10-26 14:26:59 +01:00
Kacpherek 71981a95a2 Weather: the rain and thunder fade runs even with the weather cycle off 2026-10-26 14:26:34 +01:00
Kacpherek af6b375626 Guardians: damage scales with Hard and elders, squid and axolotls must be beyond 3 blocks 2026-10-26 14:25:55 +01:00
Kacpherek 19c7a4a008 Slime block slows a walking player (SlimeBlock.stepOn)
While standing on slime with a vertical speed under 0.1 and not sneaking, the
horizontal speed is scaled by 0.4 + |vertical speed| * 0.2 every tick.
2026-10-26 14:25:37 +01:00
Kacpherek 23d546c6d5 Sleep: creative skips the monster check, bed reach and obstruction match vanilla, morning follows the time and weather rules 2026-10-26 14:24:20 +01:00
Kacpherek 3353f1b6e4 Elytra: hurt on wall hits, stop on climbables and levitation, Unbreaking wear, real gravity
Gliding into a wall now hurts like LivingEntity.handleFallFlyingCollisions
(fly_into_wall, big or small fall sound). A glide no longer starts on a
climbable or under Levitation, ends there, and a climbable (not vines) ends a
glide in progress. The glide's elytra wear goes through Unbreaking, and the
glide uses the effective gravity instead of 0.08.
2026-10-26 14:24:16 +01:00
Ludovic MarieandClaude Opus 5.5 3caf8f7014 lan_e2e portals: close the credits on the host's first End exit
The first exit from the End now rolls the poem and credits, and the host
goes home once they close (PERFORM_RESPAWN); the check asserts they show.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:24:09 +01:00
Alexander KrylovandHermes Agent 13e946a89c HUD: the hotbar draws its stacks without cloning them
`draw_item` and `draw_item_decorations` only read the game, so they take
`&self`; the hotbar loop can then hand them `self.inventory[slot].as_ref()`
and `self.offhand.as_ref()` instead of cloning the stack once per slot per
frame.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 16:20:17 +03:00
Kacpherek 7c16e83fdf Key binds: a key bound to several mappings drives all of them 2026-10-26 14:13:03 +01:00
Kacpherek d0509e2bee Witches stand still while drinking and skip potions a target already has
A drinking witch now gets the -0.25 drinking speed modifier, so it does
not walk for the 32 ticks. When it throws a potion, slowness, poison and
weakness are only picked if the target (player or mob) does not already
have that effect, as Witch.performRangedAttack does.
2026-10-26 14:02:39 +01:00
Kacpherek d057c89164 Mobs killed by a charged creeper drop their head
A creeper's explosion hurt mobs with no attacker, and the creeper is
already gone from the mob list when its blast is applied, so the
charged_creeper loot never matched. The creeper now announces itself
(ExplosionSource) before it explodes, its victims name it as the damage
source, and it stays known until they have been looted. One head drops
per charged explosion.
2026-10-26 13:59:33 +01:00
Kacpherek d622030988 Zombies call reinforcements when hurt on Hard
A hurt zombie with a target now tries up to 50 spots 7 to 40 blocks away
for a copy of itself, as Zombie.hurtServer does. The copy takes the
target, and both zombies get the charge that lowers their chance.

New zombies also roll their reinforcement chance and a leader's bonus
(Zombie.handleAttributes); before this the chance stayed at 0.
2026-10-26 13:53:34 +01:00
Kacpherek b752a821b1 Wear armor when cactus, fire, lava, campfires and other environment damage hurt the player 2026-10-26 13:53:21 +01:00
Kacpherek a489d65ab0 Arrows press wooden buttons and keep them pressed
Wooden buttons ignored arrows: a pressed button always released after
its delay. A button whose type allows arrows is now pressed when an
arrow (or a thrown trident) is inside its shape, and it stays pressed,
scheduling the release again, while one still is. Arrows now also run
the entityInside check on the blocks they pass through each tick.
2026-10-26 13:52:43 +01:00
Kacpherek 688d86db7c Villagers look for a child's bed in a full 48 block sphere and only take one they can reach
The search only covered 8 blocks up and down. It now uses the POI index over
the whole sphere, and a bed only counts when the villager has a path to it,
as VillagerMakeLove.takeVacantBed does.
2026-10-26 13:51:58 +01:00
Kacpherek 5d30776b07 Make Piercing crossbow arrows pass through entities
Crossbow arrows now get their pierce level from the Piercing enchantment, go through that many entities plus one without sticking, and are discarded at the next one. Arrows without Piercing behave as before. Pillager arrows now use the pierce level their weapon already carried.
2026-10-26 13:51:56 +01:00
Kacpherek 8178ec3dae Let a fire charge light a creeper
A creeper is now lit by any item in the creeper_igniters tag, not only
flint and steel. A fire charge is used up and plays its own sound; flint
and steel still loses durability.
2026-10-26 13:51:46 +01:00
Kacpherek 02e5c728c3 Pick the skeleton bow interval by difficulty
Skeletons now shoot every 20 ticks on Hard and every 40 otherwise
(bogged and parched: 50 and 70) instead of always using the normal
interval.
2026-10-26 13:49:57 +01:00
Kacpherek 5b0ae25ff8 Make thorns hurt the entity that hit the player
Thorns picked the nearest living mob in the world. They now hurt the mob
that caused the hit (or its projectile's owner), or the attacking player
in a player-versus-player hit.
2026-10-26 13:49:39 +01:00
Kacpherek 48c0f39e96 Farmers make bread and compost seeds when they work at a composter
A farmer working at its composter turns wheat into bread (three wheat for one
loaf, up to three loaves, while it holds at most 36 bread) and puts spare wheat
and beetroot seeds into the composter, emptying a full one first, as
WorkAtComposter does. Other professions are unchanged.
2026-10-26 13:48:15 +01:00
Kacpherek 02f222728d Respect mobGriefing for mob explosions and endermen
Mob explosions (creepers, ghast fireballs, wither skulls and the wither
spawn blast) now keep blocks when mobGriefing is off, and follow the
mob_explosion_drop_decay rule when it is on. Endermen no longer pick up
or put down blocks with mobGriefing off.
2026-10-26 13:48:13 +01:00
Kacpherek 4d0116762c Apply Unbreaking to thrown tridents and stop Riptide while riding
Throwing a trident now wears it through the same durability path as Riptide, so Unbreaking applies, and it still never breaks the trident. Riptide no longer launches a player who is riding something.
2026-10-26 13:48:08 +01:00
Kacpherek 48c289f775 Make Channeling summon lightning when a trident hits
A thrown trident with Channeling now calls down a lightning bolt in a thunderstorm, both on a mob it hurts (if the mob can see the sky) and on a lightning rod it hits (if the rod can see the sky), with the thunder sound.
2026-10-26 13:47:53 +01:00
Kacpherek d68ab989c0 Let the fishing bobber hook dropped items, add owner luck, count only fish as fish caught 2026-10-26 13:47:50 +01:00
Kacpherek 65bebdfc49 Make a sword sweep hurt the mobs next to the target
The sweep only drew the particle and played the sound. Mobs in the sweep
box now take the sweep damage (with Sweeping Edge) and 0.4 knockback.
2026-10-26 13:47:18 +01:00
Kacpherek c4b9067d6d Infinity only saves plain arrows, not tipped or spectral ones 2026-10-26 13:45:54 +01:00
Kacpherek d0af843094 Daylight detectors follow the sky and update when used
A daylight detector never changed its power: nothing ran its update.
It now has a block entity with the ticker vanilla has, and updates its
power every 20 game ticks from the sky light, the sky darkening and the
sun angle. Using it to flip inverted mode recomputes the power at once.
2026-10-26 13:44:16 +01:00
Kacpherek aec3b7e262 Scale mob damage to players with the difficulty
Damage types marked when_caused_by_living_non_player now scale when a mob
(or the owner of its projectile, or an exploding mob) caused the hit.
2026-10-26 13:43:51 +01:00
Ludovic MarieandClaude Opus 5.5 0a116d9523 Split horizon-command and horizon-client-core out of horizon-client
A pure move, the first slice of splitting the client lib into crates:

- horizon-command: the command/ module (Brigadier port, vanilla tree, argument types, selectors), as is.
- horizon-client-core: what command needs from the lib, with no other dependency inside it: text, lang, snbt,
  nbt_path, component, scoreboard, bossbar, gamerules, block_entity_nbt, fastutil_order; plus items moved out of
  lib modules that sit in the cycle, unchanged: ParticleType (particles.rs), the key display name (keybinds.rs),
  SOUND_SOURCES (settings.rs), uuid_text/uuid_from_text (save.rs), Registries/RegistryIds and parse_identifier
  (command/args.rs).

horizon-client re-exports every module and item under its old path (`horizon_client::text`,
`crate::command::Registries`, `particles::ParticleType`...), so no caller changes. Inside the moved files only
paths changed (`crate::snbt` -> `horizon_client_core::snbt` in command, `crate::command::args::parse_identifier`
-> `crate::identifier::parse_identifier`...), `sign_side` became pub, lang.rs includes the lang files from
horizon-client's assets, and gamerules' test that saves a Level moved to save.rs (it needs the client's save).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:36:05 +01:00
Ludovic MarieandClaude Opus 5.5 ebf3c1567c parity: follow code into new crates when choosing harnesses
Two changes so `parity.py --changed` keeps picking the right harnesses once code moves into a crate of its own:

- A harness recorded before the move names the old paths. A changed Rust file of a crate the recorded crates
  link (their path dependencies, transitively) but don't name now counts, and the reach search decides.
  Before, such a file matched no record and selected nothing.
- A library's root opens once reached code names the crate (`horizon_world::`, `pub use horizon_command as
  command`), instead of always. Every crate root was open, so any item of a new crate whose name matched a
  name used anywhere counted. On main the selection is the same for 15 of 16 sampled files; for
  horizon-world/src/nbt.rs one harness that reached it only through a name match drops out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:34:31 +01:00
Kacpherek 23d089aa7c Match vanilla animal breeding rules: rabbits breed, damage ends love, horse and wolf checks, breeding XP rule
Rabbits get BreedGoal and TemptGoal with rabbit food. Damage to an animal
resets love. Horses, donkeys and llamas need canParent (not ridden, tame,
adult, full health) for both parents, a sitting wolf is no partner, and the
breeding experience orb needs the mob_drops game rule.
2026-10-26 13:25:30 +01:00
Kacpherek 6c7a90cd7e Let chickens lay eggs and players get snow and chorus flowers from loot tables
Gift loot (chicken eggs, sniffer seeds) is now rolled with the mob as the
loot context's this entity, and chicken variants are known to loot
predicates, so each chicken lays its own egg colour. A block a player
breaks now has the player as this entity, so snow layers drop snowballs
and chorus flowers drop themselves.
2026-10-26 13:21:59 +01:00
Ludovic MarieandClaude Opus 5.5 ddbf2ce211 Trading probe: compare generated offers only
The probe's output is what the harness compares with vanilla; the codec
round trip lives in the unit tests, and its unwrap on a cost whose
components name an unmapped registry entry could abort the probe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:16:22 +01:00
Ludovic MarieandClaude Opus 5.5 e0fea857ce Remote dragon fireball: apply inertia before the move
AbstractHurtingProjectile.tick calls applyInertia first and then moves by
the new delta movement; Vec3.normalize leaves a near-zero vector at zero.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:15:16 +01:00
Ludovic MarieandClaude Opus 5.5 e2e591858e Fire smoke: a waterlogged neighbour doesn't burn
FireBlock.getIgniteOdds returns 0 for a waterlogged state, so canBurn is
false there and the smoke takes the other branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:11:49 +01:00
Ludovic MarieandClaude Opus 5.5 db5c10ba20 Merchant offers: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:35:09 +01:00
Eshayat Al-WasiuandCodex b0d378fe99 Apply multiplayer motion packets to mobs for squid animation
Co-Authored-By: Codex <[email protected]>
2026-10-26 17:16:07 +06:00
Kacpherek f5ef003bad Server players see the dragon's fireballs in flight
Dragon fireballs were a server-side list like the breath clouds, so a server's players never saw them. Each is now tracked as a minecraft:dragon_fireball; the client keeps it in the same list, flies it on as AbstractHurtingProjectile.tick does, snaps it to the server's moves and draws it with its dragon_breath trail.
2026-10-26 12:09:05 +01:00
Kacpherek 9ef7a6fa25 Server players see the dragon's breath clouds
The breath clouds are kept as a list, not entities, so the server thread drew their particles into its own copy and players never saw them. Each cloud is now tracked as an area_effect_cloud (radius, waiting, particle), and a server's client draws it as AreaEffectCloud.clientTick does.
2026-10-26 12:05:16 +01:00
Kacpherek 858eeba1b8 Resource packs: hand the window surface to the reloaded renderer instead of creating a new one
Applying packs dropped the window surface and created a new one for the renderer built on the same device. On Wayland with Mesa (RADV) the new surface came back lost (ERROR_SURFACE_LOST_KHR), the new renderer failed, and restoring the old one failed too, which crashed the game.
2026-10-26 11:59:33 +01:00
Ludovic Marie 9a7f955a77 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
#	docs/api/stability.md
2026-10-26 11:54:53 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol f98528bbfc Fix evoker fangs rendering on server clients
Track fang entities and deliver their start event to the existing client renderer. Exercise warmup, animation, authoritative positions and removal in the LAN harness.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:46:18 +00:00
Kacpherek 990786901a Let empty boats and minecarts enter Nether portals 2026-10-26 11:46:16 +01:00
Kacpherek 49b167ce22 Item frames: shade the frame model by face and light glow frames from 5 2026-10-26 11:45:56 +01:00
Kacpherek 1e6884c591 Show the poem and credits on a player's first End exit in server-thread mode 2026-10-26 11:43:37 +01:00
socksproxandDevin 0a2de159fd Fire: ambient smoke particles and no mob-break crumbs
BaseFireBlock.animateTick adds large smoke above a fire (three puffs in
its top half), or two puffs at each burnable neighbour's face when the
fire sits on nothing burnable or sturdy. SoulFireBlock.canBurn is
always true, so soul fire always takes the top-half branch. The
burnable table mirrors FireBlock.canBurn (fire.igniteOdds above zero)
from block-behaviours.json.

Mobs breaking blocks (MobEvent::BreakBlock) map to Level.destroyBlock,
whose Block.spawnDestroyParticles BaseFireBlock overrides to nothing:
a wither or another mob destroying fire no longer emits break
particles. Player breaks already route through player_destroy (1009);
the client-side predict_break particles are PR #2082's.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 11:39:08 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol d25d0f025b Merchant offers: preserve required potion components
Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:38:26 +00:00
Kacpherek 5389f72b9a Creative: work out the shown items again after a resource or language reload
A resource pack can rename items without changing the Search label the cache was keyed on, so a search kept its old results until the query or tab changed.
2026-10-26 11:24:41 +01:00
Kacpherek 0f6569070d Draw a GUI item's vertices once while it stays in place
Two costs a frame were repeated for every item a screen draws. ItemModels::components cloned the item's whole default component map (a JSON map of every default component) on every resolve, though most stacks carry no patch; it now borrows the defaults and copies only to apply a patch. And gui::draw submitted, culled, sorted and clipped every quad of a static item again each frame (hundreds of edge quads for a generated item, all facing sideways in the GUI), though the result for the same layers at the same position is the same; the HUD vertices of a static item are now kept per GUI position, keyed by its baked models, tints, transforms, the atlas and the exact position, and appended as they were. Items with a glint or a special model renderer are drawn as before. With the creative inventory open the HUD's share of a frame went from 0.66 ms to 0.23 ms.
2026-10-26 10:24:07 +01:00
Kacpherek 515c0bdb7d Work out the level item models read once a frame
draw_item_model built the client level state (dimension, game time, sun angle, moon phase, respawn) for every stack it drew, sampling the sky each time: about seventy times a frame with the creative inventory open, a fifth of the time spent on its items. Within a frame the level is the same for all of them, so it is kept for the frame (keyed by the frame, game time, day time and dimension).
2026-10-26 10:24:07 +01:00
Kacpherek fb9b26a06c Work out the creative grid's stacks once per tab or search, not every frame
With the creative inventory open, every frame cloned all the tabs with their stacks (twice), and items() cloned the selected tab's stacks again on each of its three or four calls a frame; on the search tab each call ran the whole search again, building every stack's localised name. That took the frame rate from about 1,900 to 980 with the inventory open and 340 while searching (#1953). Vanilla fills ItemPickerMenu.items when the tab or the search changes (selectTab, refreshSearchResults): the grid's stacks are now kept until the tab, the search text or the language changes, and the screen draws the tabs without copying their stacks.
2026-10-26 10:24:07 +01:00
Ludovic Marie 121731f261 Merge branch 'feat/server-mods-world' into feat/server-mods-commands
# Conflicts:
#	crates/horizon-client/src/lan/hub.rs
#	crates/horizon-mods/src/runtime.rs
#	docs/api/mods.md
2026-10-26 10:09:47 +01:00
Ludovic Marie 94d8130193 Merge branch 'feat/server-mods-game' into feat/server-mods-world
# Conflicts:
#	Cargo.toml
#	crates/cargo-horizon-mod/src/check.rs
#	crates/horizon-client/src/game/mods.rs
#	crates/horizon-mods/src/manifest.rs
#	crates/horizon-mods/src/runtime.rs
2026-10-26 10:04:52 +01:00
Ludovic Marie 6b7617c277 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 09:44:13 +01:00
FusselTVandClaude Opus 5.5 2143c26a2e Saves: write points of interest
Horizon finds villager POIs from the blocks and kept no poi folder, so a
world it made had none. Vanilla rebuilds a missing section on load, but
until a chunk is loaded nothing knows its beds, job sites, bells, nests,
portals or lodestones.

Saving a chunk now writes its POI records the way PoiManager saves its
sections: each section with POI blocks, valid, one record per block of
PoiTypes with its max tickets, free_tickets left out at 0. A chunk that
lost its last POI and had a record gets an empty one, since vanilla
trusts a valid section.

The records are built on the region writer thread, which reads the
saved record there, so unloading chunks reads no files on the tick
thread. A straw bed is no home (PoiTypes.BEDS is the sixteen colours);
/locate poi uses the same table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 c67a963da7 Saves: write the structure starts a chunk holds
A generated chunk keeps the starts it holds and its save writes them
into structures.starts with their current state, as vanilla saves the
StructureStart objects its chunks share. Proto chunks get their
references and starts too, so vanilla and Paper find the structures of
the chunks they finish at the edge of a generated area.

A start with a piece that cannot be saved is left out, with a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 fc0b73954f x
A container whose items no longer match its roll is saved without the
table, also when it was loaded with one, so a chest emptied after a
load does not fill again on the next one. A zero LootTableSeed is left
out, as trySaveLootTable does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 ad50996f7c Structures: save the remaining legacy pieces as vanilla does
Desert and jungle temples, swamp huts, buried treasure, ocean
monuments, mineshafts, fortresses and strongholds save the tag their
StructurePiece subclass writes, with the state placement leaves: the
height a scattered feature settled on, the buried treasure chest,
placed chests, traps and spawners, and the swamp hut's witch and cat.
With these every StructurePieceType is saved.

test_structure_start_nbt_parity gets one site of each of these kinds,
and a badlands mineshaft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 b7b36836af Structures: save template pieces as vanilla does
Igloo, shipwreck, ocean ruin, ruined portal, nether fossil, End City
and mansion pieces save the tag TemplateStructurePiece and its
subclasses write, with the state placement leaves: the height a
shipwreck or ocean ruin settled on and the igloo's moved box. Ruined
portals save all six properties, End City pieces their genDepth tag.

test_structure_start_nbt_parity gets one site of each of these kinds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 c776d6586e Structures: save jigsaw starts as StructureStart.createTag does
Horizon kept no saveable form of its structure starts, so its saves carry
an empty structures.starts and every structure it generates is invisible
to vanilla and Paper (locate, structure spawns, location predicates,
advancements) and cannot be continued past the generated area.

StructureStart::save and Piece::save write the tag vanilla writes. This
covers the pieces of jigsaw pools (villages, pillager outposts, ancient
cities, trail ruins, trial chambers, abandoned camps, bastions) with
their pool element, junctions and liquid settings. Junctions keep
delta_y and dest_proj, pool elements their processors as written.
LegacyPiece::save defaults to None; a start with such a piece is not
saved, as before.

test_structure_start_nbt_parity compares the tags with the pinned
server's own for one site of every jigsaw structure kind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
rdbtCVSandCodex GPT-6.1 Sol 66f32a7a3a Persist forced chunks across world restarts
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-10-26 07:45:35 +00:00
Ludovic MarieandClaude Opus 5.5 7c5efff307 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:31:10 +01:00
Eshayat Al-WasiuandOpenAI GPT-6.1 Sol 606efe5af0 Animate remote squid bodies and tentacles from server velocity
Run the client branch of Squid.aiStep, preserve authoritative movement and synchronize tentacle-cycle resets with entity event 19.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 13:16:54 +06:00
Eshayat Al-WasiuandOpenAI GPT-6.1 Sol d422564ab3 Respect tool restrictions on Creative block destruction
Check the tool component before server destruction, local mining and remote prediction. Cover vanilla item defaults, overrides and mode-switch cancellation.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 13:16:45 +06:00
Ludovic MarieandClaude Opus 5.5 1f6e498238 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:48:21 +01:00
Ludovic MarieandClaude Opus 5.5 32f6079134 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 ce1db1b0c3 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:16 +01:00
Ludovic Marie ad3ddeb43f Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/horizon-mods/tests/sandbox.rs
2026-10-26 01:43:14 +01:00
Ludovic Marie 34f4d3b451 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 00:25:27 +01:00
Ludovic MarieandClaude Opus 5.5 27de895fdb Mods in Lua: horizon-mod-lua, a Lua 5.4 interpreter built into the mod
A mod's code can be a Lua script. The crate horizon-mod-lua embeds
piccolo (pure Rust Lua, MIT) in the wasm guest with the script, so a Lua
mod is an .hmod like any other: same sandbox, capabilities, fuel and
checks. `horizon_mod_lua::lua_mod!("main.lua")` is the whole Rust side.

- The script runs once at load and registers commands with
  horizon.command; hooks are globals (on_tick, on_frame, on_command,
  on_chat). Capabilities are tables of `horizon` (game, chat, hud,
  storage), each behind a cargo feature so unused imports aren't linked.
- piccolo's stdlib lacks them: string.format (C flags, width,
  precision), string.rep, table.insert/remove/concat.
- The sandbox has no entropy: getrandom 0.2 and 0.3 get a custom
  SplitMix64 backend (cfg getrandom_backend="custom", set by
  cargo horizon-mod build).
- Example coords-hud-lua: coords-hud line for line. The sandbox tests
  run the same checks on both, and measure a tick of each.
- cargo horizon-mod new --template lua.
- Docs: docs/mods.md, website "Write it in Lua", changelog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:32:50 +01:00
Ludovic MarieandClaude Opus 5.5 574d65a03c stability.md: server mods' row names world and typed commands
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:08:47 +01:00
Ludovic MarieandClaude Opus 5.5 c5bb51bb05 Mod API 0.3: typed server commands, in players' command trees
A server mod's registration now lists each command's arguments: a name, a
kind (word, text, integer with bounds, number, boolean, player, block-pos)
and whether it is optional. The server adds them to the command tree every
player receives, after vanilla's commands, so a player's chat box completes
and colours them as vanilla's. When a player sends one, the server parses
the line as vanilla's parsers do (horizon-mods' commands.rs: ~ coordinates
from the player, @s/@p, integer bounds) and calls the mod's command hook
with the values by name, or answers with vanilla's error without calling
the mod.

The hub keeps the server mods' commands as a root in the commands report's
format and resends players' trees when they change (CommandTree::
packet_with); the game sets it when its mods start, stop or reload, or a
hub opens. In-frame, the window's own chat box lists them by name.

SDK: Registration::command_with(name, [Argument::...]), Argument builders,
Args (text(), integer(), player(), block_pos() ...); command(name) is free
text, an optional text argument. Examples: welcome's /mined [player],
beacons' /beacon [height] and /ground [pos], with scripts for values and
errors.

Docs: docs/mods.md (typed commands, kinds table), docs/api/mods.md and
the regenerated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:35:09 +01:00
Ludovic MarieandClaude Opus 5.5 35a89eaebe Mod API 0.3: capability world for server mods (blocks, biomes)
Server mods get interface world: get-block(dimension, x, y, z) reads a
loaded chunk's block state as "minecraft:oak_log[axis=y]", get-biome its
biome ("minecraft:plains"), and
set-blocks(dimension, changes) sets up to 4096 blocks per call, checked at
once, made as /setblock makes them (place_state: neighbours, light, block
entities, every client told through the block log) after the call, before
that dimension's next tick.

Reads cost no copy: for a mod call the game lends Mods the levels' chunk
maps (moved out with mem::take and put back after), only while a running
mod has world. Writes for a level that isn't ticking (another dimension,
in-frame) wait for it, up to 65,536.

Test scripts get blocks (setup) and expect.blocks; cargo horizon-mod check
reports a trial run's block writes. Example beacons: /beacon builds a
glowstone pillar beside the player, /ground reads the block underfoot and the biome.
parse_state is now a free function so the mod path shares it.

Docs: docs/mods.md (the capability, script keys), docs/api/mods.md and the
regenerated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:55:29 +01:00
635 changed files with 38721 additions and 19930 deletions

No files matched your search

-1
View File
@@ -2,7 +2,6 @@
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
+1 -1
View File
@@ -2,5 +2,5 @@
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+40
View File
@@ -72,7 +72,46 @@ 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
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request change the launcher or a crate it builds?
if: github.event_name == 'pull_request'
id: launcher
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust and the headers GPUI links against
if: steps.launcher.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false'
with:
workspaces: launcher
shared-key: launcher
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check the launcher's formatting, lint and tests
if: steps.launcher.outputs.required != 'false'
working-directory: launcher
run: |
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
@@ -108,6 +147,7 @@ jobs:
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
+2 -2
View File
@@ -25,8 +25,8 @@ jobs:
parity-main:
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
# behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs
# make a core-crate run several times longer, and a failure here bisects and comments on merges.
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
+43 -62
View File
@@ -1,6 +1,6 @@
name: parity
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's
# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the
# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
on:
workflow_call:
@@ -13,6 +13,13 @@ on:
description: The commit to check out and test
type: string
required: true
full:
description: >-
The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it
runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is
busy the job waits for it.
type: boolean
default: false
permissions:
contents: read
env:
@@ -21,40 +28,53 @@ env:
jobs:
parity:
runs-on: [self-hosted, horizon-game]
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }}
# A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4
# harnesses at a time) up to a few hours, and any pull request's job may land there.
timeout-minutes: ${{ inputs.full && 360 || 180 }}
steps:
- uses: actions/checkout@v4
with:
# The commit the event names: a fork's later pushes need another run-parity label.
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
# main into it.
ref: ${{ inputs.head }}
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- name: The head merged into main (what merging it would land)
id: merge
env:
BASE_SHA: ${{ inputs.base }}
HEAD_SHA: ${{ inputs.head }}
run: |
# Harness fixes and game fixes that reached main since the branch was made then apply here too: a head
# tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before
# #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull
# request's own changes. A head that conflicts with main is tested alone.
set -euo pipefail
git merge --abort 2>/dev/null || true
git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main
main=$(git rev-parse origin/main)
if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then
echo "base=$main" >> "$GITHUB_OUTPUT"
echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY"
else
git merge --abort
echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT"
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
fi
- uses: dtolnay/rust-toolchain@stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
env:
BASE_SHA: ${{ inputs.base }}
run: |
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
set -euo pipefail
if git rev-parse --verify -q origin/main >/dev/null \
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
bash "$RUNNER_TEMP/vanilla-data.sh"
else
bash tools/ci/vanilla-data.sh
fi
run: bash tools/ci/vanilla-data.sh
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
env:
BASE_SHA: ${{ inputs.base }}
BASE_SHA: ${{ steps.merge.outputs.base }}
run: |
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
# the tool nor the number, so there is nothing to check.
# A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number.
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
exit 0
@@ -65,47 +85,8 @@ jobs:
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ inputs.base }}
run: |
# main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
# branch made before a better selection landed gets it too; the head's own runner then runs those
# harnesses. A branch that changes the selection itself selects with its own code.
set -euo pipefail
own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
fork_point=$(git merge-base "$BASE_SHA" HEAD)
if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
own
exit
fi
had_reach=$(git ls-files tools/parity_reach.py)
git show origin/main:tools/parity.py > tools/parity.py
git show origin/main:tools/parity_reach.py > tools/parity_reach.py
status=0
python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
git checkout -- tools/parity.py
if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
cat .local/parity-selection.txt
if [ "$status" != 0 ]; then
echo "main's selection failed: the head's own selection runs"
own
exit
fi
names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
# The summary line says how many would run: a count that doesn't match the names read (a format change)
# must not let the job pass untested.
expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
count=$(printf '%s' "$names" | grep -c . || true)
if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
own
exit
fi
if [ "$count" = 0 ]; then
echo "Nothing to run."
exit 0
fi
# shellcheck disable=SC2086
python3 tools/parity.py $names --timeout 2400
BASE_SHA: ${{ steps.merge.outputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
+1
View File
@@ -22,3 +22,4 @@ jobs:
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
+11 -6
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
@@ -34,7 +35,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -54,7 +57,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
@@ -62,6 +66,7 @@ unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/
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
@@ -117,16 +122,16 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
+2 -2
View File
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
Generated
+155 -2
View File
@@ -216,6 +216,13 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "beacons"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -470,6 +477,13 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "coords-hud-lua"
version = "0.1.0"
dependencies = [
"horizon-mod-lua",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -923,6 +937,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"
@@ -1086,6 +1107,30 @@ dependencies = [
"slab",
]
[[package]]
name = "gc-arena"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
dependencies = [
"allocator-api2",
"gc-arena-derive",
"hashbrown 0.14.5",
"sptr",
]
[[package]]
name = "gc-arena-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1250,6 +1295,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1365,6 +1420,8 @@ dependencies = [
"glam",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
@@ -1396,6 +1453,23 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-client-core"
version = "0.1.0"
dependencies = [
"horizon-world",
"serde_json",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-protocol",
"horizon-world",
]
[[package]]
name = "horizon-core"
version = "0.1.0"
@@ -1435,6 +1509,16 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-mod-lua"
version = "0.1.0"
dependencies = [
"getrandom 0.2.17",
"getrandom 0.3.4",
"horizon-mod-sdk",
"piccolo",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
@@ -2711,6 +2795,21 @@ dependencies = [
"indexmap",
]
[[package]]
name = "piccolo"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
dependencies = [
"ahash",
"allocator-api2",
"anyhow",
"gc-arena",
"hashbrown 0.14.5",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2838,6 +2937,15 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
@@ -2938,6 +3046,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -2971,6 +3100,13 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "recipes"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -3410,6 +3546,12 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sptr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3527,6 +3669,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -5188,7 +5341,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -5229,7 +5382,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+43 -1
View File
@@ -1,6 +1,48 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-threads",
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-command",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
"crates/horizon-protocol",
"crates/horizon-theme",
"crates/horizon-prepare",
"crates/horizon-telemetry",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mod-sdk",
"crates/horizon-mods",
"crates/horizon-mods/tests/guest",
"crates/horizon-mods/tests/ui_guest",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/coords-hud-lua",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
[workspace.package]
+8 -4
View File
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
</details>
@@ -310,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
@@ -600,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
</details>
@@ -936,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
+13 -1
View File
@@ -200,7 +200,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
return Err(format!("it didn't start: {reason}"));
}
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
@@ -262,9 +262,21 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
Output::QuietCommands { lines, .. } => {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::SetBlocks {
dimension, blocks, ..
} => {
format!("set {} block(s) in {dimension}", blocks.len())
}
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::PlaceRecipe { book_id, all } => {
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
}
Output::Give { item, count, .. } => format!("gave {count} {item}"),
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
+54 -8
View File
@@ -1,7 +1,7 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -26,6 +26,8 @@ use std::{
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
/// The Lua runtime from the same repository, with the template's capability.
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
@@ -42,7 +44,7 @@ fn main() -> ExitCode {
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
let (sdk, lua) = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
(
format!("{{ path = {:?} }}", p.display().to_string()),
format!(
"{{ path = {:?}, features = [\"chat\"] }}",
lua.display().to_string()
),
)
}
None => SDK_GIT.to_string(),
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
.replace("{lua}", &lua)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
@@ -146,10 +156,19 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
// The code template's mod, written in Lua.
let lua_files = [
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
("main.lua", include_str!("../template/lua/main.lua")),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
"lua" => &lua_files,
other => return Err(format!("no template {other}: code, content or lua")),
};
for &(file, text) in files {
let file = fill(file);
@@ -215,6 +234,13 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
// takes a custom source only with this cfg.
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
cmd.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
);
cmd.current_dir(root).args([
"build",
"--lib",
@@ -386,6 +412,26 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_lua_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
let path = dir.join("hello-lua");
new(&[
path.display().to_string(),
"--template".into(),
"lua".into(),
])
.unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
assert!(script.contains("horizon.command(\"hello\")"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
@@ -424,7 +470,7 @@ mod tests {
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
"python".into()
])
.is_err()
);
@@ -0,0 +1,17 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
# The Lua interpreter, built into the mod with main.lua. Its features are the
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
# the two lists the same.
[dependencies]
horizon-mod-lua = {lua}
[profile.release]
opt-level = "s"
lto = true
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the Lua runtime targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,12 @@
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
-- Commands come from horizon.command; hooks are global functions:
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
@@ -0,0 +1,2 @@
// The mod is main.lua: this builds it into the mod with its interpreter.
horizon_mod_lua::lua_mod!("main.lua");
+10 -7
View File
@@ -1031,19 +1031,22 @@ pub struct ItemModels {
}
impl ItemModels {
pub fn components(&self, stack: &StackView) -> Components {
let mut out = self
.prototypes
.get(&stack.item)
.cloned()
.unwrap_or_default();
/// The stack's components: its item's defaults with the stack's patch on top. A stack
/// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
static NONE: Components = BTreeMap::new();
let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
if stack.patch.is_empty() {
return std::borrow::Cow::Borrowed(prototype);
}
let mut out = prototype.clone();
for (key, value) in &stack.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
out
std::borrow::Cow::Owned(out)
}
/// `ItemModelResolver.updateForTopItem`.
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-client-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
[dependencies]
horizon-world = { path = "../horizon-world" }
serde_json = { version = "1.0.151", features = ["raw_value"] }
[lints]
workspace = true
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The loaded `SignText` of one side, or the empty text.
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
pub fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
@@ -252,7 +252,7 @@ pub fn to_text_with(
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
resolve(sel)
} else if let Some(key) = o.get("keybind").and_then(Value::as_str) {
crate::keybinds::display_name(key)
crate::key_display::display_name(key)
} else {
Text::empty()
};
@@ -292,27 +292,4 @@ mod tests {
assert_eq!(GameRules::load(&dir), rules);
let _ = fs::remove_dir_all(dir);
}
/// The world's save (Level.write) writes the rules as they are now, and a
/// world opened again reads them back (#74).
#[test]
fn level_save_keeps_changed_rules() {
let dir =
std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut level = crate::save::Level::default();
level.write(&dir).unwrap();
// Rules changed while playing, then saved and loaded again.
let mut rules = GameRules::load(&dir);
assert_eq!(rules, GameRules::default());
assert!(rules.set("advance_time", Value::Bool(false)));
assert!(rules.set("advance_weather", Value::Bool(false)));
assert!(rules.set("random_tick_speed", Value::Int(0)));
level.game_rules = rules.by_name();
level.write(&dir).unwrap();
assert_eq!(GameRules::load(&dir), rules);
let read = crate::save::Level::read(&dir).unwrap();
assert_eq!(read.game_rules, rules.by_name());
assert_eq!(read.game_rule("advance_time"), 0);
let _ = fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,18 @@
//! Resource identifiers (`Identifier`).
/// Identifier.parse: `ns:path` or `path`, validated.
pub fn parse_identifier(raw: &str) -> Option<String> {
let (ns, path) = match raw.split_once(':') {
Some(("", path)) => ("minecraft", path),
Some((ns, path)) => (ns, path),
None => ("minecraft", raw),
};
let ns_ok = ns != ".."
&& ns
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
@@ -0,0 +1,38 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
&& !crate::lang::has(key)
{
return Text::translate("key.mouse", &[Text::literal(n)]);
}
Text::translate(key, &[])
}
@@ -42,8 +42,12 @@ struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
+23
View File
@@ -0,0 +1,23 @@
//! 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`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
pub mod component;
pub mod fastutil_order;
pub mod gamerules;
pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod particle_type;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -0,0 +1,209 @@
//! Particle types (`ParticleTypes`): the vanilla particle types with a client
//! implementation, by registry name, and their per-type flags.
macro_rules! particle_types {
($($variant:ident = $name:literal),* $(,)?) => {
/// Vanilla particle types with a client implementation here.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ParticleType { $($variant),* }
impl ParticleType {
pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*];
/// Registry path (`minecraft:<name>`), also the sprite-set file name.
pub fn name(self) -> &'static str {
match self { $(ParticleType::$variant => $name),* }
}
pub fn from_name(name: &str) -> Option<Self> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
match name { $($name => Some(ParticleType::$variant),)* _ => None }
}
}
};
}
particle_types! {
Block = "block",
BlockCrumble = "block_crumble",
Trail = "trail",
DustPillar = "dust_pillar",
FallingDust = "falling_dust",
Smoke = "smoke",
LargeSmoke = "large_smoke",
WhiteSmoke = "white_smoke",
Flame = "flame",
SoulFireFlame = "soul_fire_flame",
CopperFireFlame = "copper_fire_flame",
SmallFlame = "small_flame",
Lava = "lava",
DrippingWater = "dripping_water",
FallingWater = "falling_water",
DrippingLava = "dripping_lava",
FallingLava = "falling_lava",
LandingLava = "landing_lava",
DrippingHoney = "dripping_honey",
FallingHoney = "falling_honey",
LandingHoney = "landing_honey",
FallingNectar = "falling_nectar",
FallingSporeBlossom = "falling_spore_blossom",
SporeBlossomAir = "spore_blossom_air",
DrippingObsidianTear = "dripping_obsidian_tear",
FallingObsidianTear = "falling_obsidian_tear",
LandingObsidianTear = "landing_obsidian_tear",
DrippingDripstoneWater = "dripping_dripstone_water",
FallingDripstoneWater = "falling_dripstone_water",
DrippingDripstoneLava = "dripping_dripstone_lava",
FallingDripstoneLava = "falling_dripstone_lava",
Splash = "splash",
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
Underwater = "underwater",
Portal = "portal",
ReversePortal = "reverse_portal",
Enchant = "enchant",
Nautilus = "nautilus",
VaultConnection = "vault_connection",
Crit = "crit",
EnchantedHit = "enchanted_hit",
DamageIndicator = "damage_indicator",
Heart = "heart",
AngryVillager = "angry_villager",
HappyVillager = "happy_villager",
Composter = "composter",
Mycelium = "mycelium",
Dolphin = "dolphin",
EggCrack = "egg_crack",
Poof = "poof",
Explosion = "explosion",
ExplosionEmitter = "explosion_emitter",
CampfireCosySmoke = "campfire_cosy_smoke",
CampfireSignalSmoke = "campfire_signal_smoke",
Note = "note",
Dust = "dust",
TotemOfUndying = "totem_of_undying",
Firework = "firework",
EndRod = "end_rod",
Ash = "ash",
WhiteAsh = "white_ash",
CrimsonSpore = "crimson_spore",
WarpedSpore = "warped_spore",
CherryLeaves = "cherry_leaves",
PaleOakLeaves = "pale_oak_leaves",
TintedLeaves = "tinted_leaves",
Effect = "effect",
InstantEffect = "instant_effect",
EntityEffect = "entity_effect",
Witch = "witch",
Infested = "infested",
Cloud = "cloud",
Sneeze = "sneeze",
Snowflake = "snowflake",
Soul = "soul",
SculkSoul = "sculk_soul",
DragonBreath = "dragon_breath",
Gust = "gust",
SmallGust = "small_gust",
GustEmitterLarge = "gust_emitter_large",
GustEmitterSmall = "gust_emitter_small",
DustColorTransition = "dust_color_transition",
SculkCharge = "sculk_charge",
SculkChargePop = "sculk_charge_pop",
Shriek = "shriek",
SonicBoom = "sonic_boom",
Vibration = "vibration",
SweepAttack = "sweep_attack",
Glow = "glow",
WaxOn = "wax_on",
WaxOff = "wax_off",
ElectricSpark = "electric_spark",
Scrape = "scrape",
SquidInk = "squid_ink",
GlowSquidInk = "glow_squid_ink",
Item = "item",
ItemSlime = "item_slime",
ItemSnowball = "item_snowball",
ItemCobweb = "item_cobweb",
SulfurCubeGoo = "sulfur_cube_goo",
Flash = "flash",
Firefly = "firefly",
TrialSpawnerDetection = "trial_spawner_detection",
TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous",
TrialOmen = "trial_omen",
RaidOmen = "raid_omen",
OminousSpawning = "ominous_spawning",
Spit = "spit",
Fishing = "fishing",
DustPlume = "dust_plume",
RedPoplarLeaves = "red_poplar_leaves",
OrangePoplarLeaves = "orange_poplar_leaves",
YellowPoplarLeaves = "yellow_poplar_leaves",
PauseMobGrowth = "pause_mob_growth",
ResetMobGrowth = "reset_mob_growth",
BlockMarker = "block_marker",
}
impl ParticleType {
/// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`):
/// spawned regardless of distance.
pub fn override_limiter(self) -> bool {
matches!(
self,
Self::BlockMarker
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
| Self::Gust
| Self::GustEmitterLarge
| Self::GustEmitterSmall
| Self::SonicBoom
| Self::SculkCharge
| Self::SculkChargePop
| Self::Vibration
| Self::Poof
| Self::Spit
| Self::SquidInk
| Self::SweepAttack
| Self::CampfireCosySmoke
| Self::CampfireSignalSmoke
| Self::GlowSquidInk
| Self::Glow
| Self::WaxOn
| Self::WaxOff
| Self::ElectricSpark
| Self::Scrape
| Self::TrialSpawnerDetection
| Self::TrialSpawnerDetectionOminous
| Self::VaultConnection
| Self::OminousSpawning
)
}
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
/// hardcodes the translucent layer, drawn after translucent terrain.
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
/// sprite set on the engine.
pub fn translucent_layer(self) -> bool {
match self {
// CampfireSmokeParticle.
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
// PlayerCloudParticle.
| Self::Cloud | Self::Sneeze
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
| Self::Vibration | Self::Firefly | Self::Flash
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
// SparkParticle (firework), SquidInkParticle, TotemParticle.
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
| Self::TotemOfUndying
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
| Self::VaultConnection => true,
_ => false,
}
}
}
@@ -0,0 +1,188 @@
//! Registry contents and source state that commands validate and complete
//! arguments against (what the server's registry access holds).
use std::collections::BTreeMap;
/// Registry contents and source state used to validate and complete arguments.
#[derive(Clone, Debug, Default)]
pub struct Registries {
/// Permission level of the command source.
pub permission: u8,
pub items: Vec<String>,
pub item_tags: Vec<String>,
/// Block id -> (property, allowed values).
pub blocks: BTreeMap<String, Vec<(String, Vec<String>)>>,
pub block_tags: Vec<String>,
/// Entity types that `summon` can create.
pub summonable: Vec<String>,
/// Every entity type (selector `type=`).
pub entity_types: Vec<String>,
pub effects: Vec<String>,
pub enchantments: Vec<String>,
pub structures: Vec<String>,
pub biomes: Vec<String>,
pub dimensions: Vec<String>,
pub clocks: Vec<String>,
pub timelines: Vec<String>,
pub time_markers: Vec<String>,
pub advancements: Vec<String>,
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
pub tracked: Vec<String>,
/// Loaded datapack functions (`ns:path`) and function tags (`ns:path`).
pub functions: Vec<String>,
/// `/data ... storage` ids.
pub storages: Vec<String>,
/// Boss bar ids and datapack predicate ids.
pub bossbars: Vec<String>,
pub predicates: Vec<String>,
pub item_modifiers: Vec<String>,
pub schedules: Vec<String>,
pub stopwatches: Vec<String>,
pub recipes: Vec<String>,
pub sounds: Vec<String>,
pub damage_types: Vec<String>,
pub attributes: Vec<String>,
pub loot_tables: Vec<String>,
pub templates: Vec<String>,
pub random_sequences: Vec<String>,
pub datapacks_enabled: Vec<String>,
pub datapacks_disabled: Vec<String>,
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
/// Blocks whose default state has a block entity.
pub block_entities: std::collections::BTreeSet<String>,
/// Block tag -> member blocks.
pub block_tag_members: BTreeMap<String, Vec<String>>,
/// Data component types with a codec (not transient).
pub persistent_components: Vec<String>,
/// Recipes `/recipe` can give (not special).
pub learnable_recipes: Vec<String>,
/// Advancement id -> criterion names.
pub advancement_criteria: BTreeMap<String, Vec<String>>,
/// World clock -> time markers shown in commands.
pub clock_time_markers: BTreeMap<String, Vec<String>>,
/// Trigger objectives the source may use (`/trigger` suggestions).
pub trigger_objectives: Vec<String>,
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
}
/// The ids and tag ids of one registry.
#[derive(Clone, Debug, Default)]
pub struct RegistryIds {
pub ids: Vec<String>,
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
impl Registries {
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
/// mob effects, data component types and dialogs, block entities and tag
/// members. Registries that data packs reload stay with the game.
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
fn strings(v: &serde_json::Value) -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
let fill = |field: &mut Vec<String>, key: &str| {
if field.is_empty() {
*field = strings(&r[key]);
}
};
fill(&mut self.effects, "effects");
fill(&mut self.enchantments, "enchantments");
fill(&mut self.attributes, "attributes");
fill(&mut self.damage_types, "damage_types");
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
"minecraft:loot_table",
"minecraft:predicate",
"minecraft:item_modifier",
];
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
continue;
}
self.by_registry.insert(
name.clone(),
RegistryIds {
ids: strings(&entry["ids"]),
tags: strings(&entry["tags"]),
},
);
}
if self.block_entities.is_empty() {
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
}
if self.block_tag_members.is_empty() {
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
self.block_tag_members.insert(tag.clone(), strings(members));
}
}
if self.clock_time_markers.is_empty() {
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
self.clock_time_markers
.insert(clock.clone(), strings(markers));
}
}
if self.advancement_criteria.is_empty() {
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
self.advancement_criteria
.insert(id.clone(), strings(criteria));
}
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.ids;
}
match registry {
"minecraft:item" => &self.items,
"minecraft:entity_type" => &self.entity_types,
"minecraft:mob_effect" => &self.effects,
"minecraft:enchantment" => &self.enchantments,
"minecraft:worldgen/structure" => &self.structures,
"minecraft:worldgen/biome" => &self.biomes,
"minecraft:world_clock" => &self.clocks,
"minecraft:timeline" => &self.timelines,
"minecraft:attribute" => &self.attributes,
"minecraft:damage_type" => &self.damage_types,
"minecraft:sound_event" => &self.sounds,
"minecraft:predicate" => &self.predicates,
"minecraft:item_modifier" => &self.item_modifiers,
"minecraft:loot_table" => &self.loot_tables,
"minecraft:recipe" => &self.recipes,
"minecraft:advancement" => &self.advancements,
_ => EMPTY,
}
}
/// The tag ids of a registry (sorted).
pub fn tags(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.tags;
}
match registry {
"minecraft:item" => &self.item_tags,
"minecraft:block" => &self.block_tags,
_ => EMPTY,
}
}
}
@@ -2,9 +2,9 @@
//! `ObjectiveCriteria`, `NumberFormat`) and its saved data
//! (`data/minecraft/scoreboard.dat`, `ScoreboardSaveData.Packed`).
use crate::{
command::Registries,
component,
fastutil_order::{HashSetOrder, OpenOrder},
registries::Registries,
text::{self, Style, Text},
};
use horizon_world::nbt::Tag;
@@ -1266,7 +1266,7 @@ fn entity_uuids(tag: &mut Tag) {
})
.collect::<Option<Vec<i32>>>(),
Tag::String(s) => {
crate::save::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
crate::uuid::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
}
_ => None,
};
File renamed without changes.
@@ -0,0 +1,7 @@
//! Sound sources (`SoundSource`), by name.
/// SoundSource names in vanilla order.
pub const SOUND_SOURCES: [&str; 11] = [
"master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient",
"voice", "ui",
];
@@ -67,7 +67,7 @@ pub struct Style {
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
crate::command::args::parse_identifier(v.as_str()?)
crate::identifier::parse_identifier(v.as_str()?)
}
impl Style {
pub fn color(c: u32) -> Self {
+24
View File
@@ -0,0 +1,24 @@
//! UUIDs as vanilla's four-int form, to and from their text form.
pub fn uuid_text(u: [i32; 4]) -> String {
let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
return None;
}
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
}
Some(out)
}
+2
View File
@@ -9,6 +9,8 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -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()
@@ -0,0 +1,48 @@
use horizon_client::{
Result,
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let items = Items::catalog(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let mut stack = ItemStack::new(
row["item"].as_str().unwrap(),
row["count"].as_u64().unwrap() as u32,
);
match row["tool"].as_str().unwrap() {
"default" => {}
mode => {
let mut data = StackData::default();
let tool = if mode == "implicit" {
Tag::compound([] as [(&str, Tag); 0])
} else {
Tag::compound([(
"can_destroy_blocks_in_creative",
Tag::Byte(i8::from(mode == "allow")),
)])
};
data.set_other("minecraft:tool", Some(tool));
stack.extra = Some(Box::new(data));
}
}
writeln!(
out,
"{}",
items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap())
)?;
}
Ok(())
}
@@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> {
pose,
num("maxCrossbowChageDuration"),
num("ticksUsingItem"),
false,
);
}
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
@@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -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 {
@@ -27,6 +27,7 @@ mod chat_screen_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
mod emitter_particles_probe;
@@ -84,6 +85,7 @@ mod sound_engine_fixture;
mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
@@ -108,6 +110,8 @@ probes! {
block_transform_fixture,
breaking_fixture,
camera_probe,
squid_client_probe,
creative_mining_probe,
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
@@ -1057,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
effects: Vec::new(),
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
@@ -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();
@@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
.iter()
.filter_map(|e| match e {
MotionEvent::Fall(e) => Some(e.trace()),
// The oracle's player wears no armor; the game applies this one.
MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None,
MotionEvent::Health(e) => Some(e.trace()),
MotionEvent::Snowflake { position, velocity } => Some(format!(
"snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}",
@@ -0,0 +1,51 @@
use horizon_client::{
Result,
mobs::{Mob, MobTypes, aquatic},
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
sync::Arc,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let types = MobTypes::load(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let kind = Arc::new(
types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(),
);
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
mob.fluids.in_water = row["water"].as_bool().unwrap();
mob.velocity = Vec3::new(
row["x"].as_f64().unwrap(),
row["y"].as_f64().unwrap(),
row["z"].as_f64().unwrap(),
);
mob.body_yaw = row["body"].as_f64().unwrap() as f32;
for (i, value) in row["v"].as_array().unwrap().iter().enumerate() {
mob.anim.v[i] = value.as_f64().unwrap() as f32;
}
if row["reset"].as_bool().unwrap() {
aquatic::squid_cycle_event(&mut mob);
}
let velocity = mob.velocity;
aquatic::squid_client_step(&mut mob);
assert_eq!(
velocity, mob.velocity,
"a remote animation must preserve packet velocity"
);
write!(out, "{:08x}", mob.body_yaw.to_bits())?;
for value in &mob.anim.v[..6] {
write!(out, " {:08x}", value.to_bits())?;
}
writeln!(out)?;
}
Ok(())
}
+231 -17
View File
@@ -35,8 +35,9 @@ 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).
Leaves(Option<(T, f32)>, Option<u32>),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -49,6 +50,8 @@ enum Animate {
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
RedstoneWire {
power: u8,
@@ -58,7 +61,10 @@ enum Animate {
BubbleColumn {
drag: bool,
},
Fire,
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
Fire {
soul: bool,
},
/// An active sculk sensor.
SculkSensor,
/// Potent sulfur that is not dry.
@@ -101,10 +107,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),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
// 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))
}
"spruce_leaves" => Animate::Leaves(None, None),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -116,6 +129,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"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);
@@ -140,7 +161,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"bubble_column" => Animate::BubbleColumn {
drag: prop("drag").as_deref() == Some("true"),
},
"fire" | "soul_fire" => Animate::Fire,
"fire" => Animate::Fire { soul: false },
"soul_fire" => Animate::Fire { soul: true },
"dried_ghast" => Animate::DriedGhast {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
@@ -157,6 +179,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;
@@ -190,6 +233,8 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: 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`).
ambient: Vec<Vec<(T, f32)>>,
}
@@ -220,6 +265,21 @@ impl AnimateTables {
})
.collect()
};
let behaviour: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
let burnable = (0..registry.len() as u32)
.map(|id| {
// FireBlock.getIgniteOdds: a waterlogged state never burns.
registry.state(BlockStateId(id)).is_some_and(|s| {
s.properties.get("waterlogged").map(String::as_str) != Some("true")
&& ignite_odds
.and_then(|m| m.get(s.name.as_str()))
.and_then(serde_json::Value::as_i64)
.is_some_and(|odds| odds > 0)
})
})
.collect();
let mut ambient = vec![Vec::new(); biomes.list.len()];
for (name, &id) in &biomes.index {
let path = root.join(format!(
@@ -248,6 +308,7 @@ 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"),
burnable,
ambient,
})
}
@@ -482,6 +543,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,
@@ -663,7 +771,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling) => {
Animate::Leaves(falling, fixed_tint) => {
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
@@ -683,7 +791,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 {
@@ -955,6 +1063,13 @@ fn block_animate_tick(
}
}
}
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 [
[0, -1, 0],
@@ -1103,7 +1218,7 @@ fn block_animate_tick(
}
}
}
Animate::Fire => {
Animate::Fire { soul } => {
let r = engine.random();
if next_int(r, 24) == 0 {
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
@@ -1115,6 +1230,55 @@ fn block_animate_tick(
pitch,
);
}
// BaseFireBlock.animateTick: three large smoke above the fire, or
// two at each burnable neighbour's face when nothing burnable or
// sturdy supports it (SoulFireBlock.canBurn is always true).
let can_burn = |p: [i32; 3]| {
soul || tables
.burnable
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let below = [pos[0], pos[1] - 1, pos[2]];
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
if !can_burn(below) && !sturdy {
// (neighbour, coordinate axis, face at the axis' high end): the
// west, east, north, south and above faces, in vanilla order.
for (at, axis, high) in [
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
([pos[0] + 1, pos[1], pos[2]], 0, true),
([pos[0], pos[1], pos[2] - 1], 2, false),
([pos[0], pos[1], pos[2] + 1], 2, true),
([pos[0], pos[1] + 1, pos[2]], 1, true),
] {
if !can_burn(at) {
continue;
}
for _ in 0..2 {
let r = engine.random();
let d = [r.next_double(), r.next_double(), r.next_double()];
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
// The face coordinate hugs the edge: `* 0.1F` (float).
p[axis] = if high {
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
} else {
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
};
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
} else {
for _ in 0..3 {
let r = engine.random();
let p = [
fx + r.next_double(),
fy + r.next_double() * 0.5 + 0.5,
fz + r.next_double(),
];
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
}
}
}
@@ -1138,32 +1302,71 @@ 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;
for _ in 0..count {
make_campfire_smoke(engine, pos, signal);
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).
pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: bool) {
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
/// `smoking` adds the plain smoke puff at the logs.
pub fn make_campfire_smoke(
engine: &mut ParticleEngine,
pos: [i32; 3],
signal: bool,
smoking: bool,
) {
let r = engine.random();
let sign = |r: &mut LegacyRandom| if r.next_boolean() { 1.0 } else { -1.0 };
let dx = r.next_double() / 3.0;
@@ -1177,6 +1380,17 @@ pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: b
T::CampfireCosySmoke
};
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
if smoking {
let r = engine.random();
let dx = r.next_double() / 4.0 * sign(r);
let dz = r.next_double() / 4.0 * sign(r);
let at = [
f64::from(pos[0]) + 0.5 + dx,
f64::from(pos[1]) + 0.4,
f64::from(pos[2]) + 0.5 + dz,
];
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
}
}
#[cfg(test)]
+102 -7
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)]
@@ -156,7 +160,11 @@ pub struct ArrowState {
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
/// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone
/// through (cleared when it sticks in a block).
pub pierced: Vec<Who>,
/// `Entity.remainingFireTicks`.
pub fire_ticks: i32,
pub fall_distance: f64,
@@ -165,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,
@@ -199,6 +210,7 @@ impl ArrowState {
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
no_physics: false,
pierce: 0,
pierced: Vec::new(),
fire_ticks: 0,
fall_distance: 0.0,
fluids: EntityFluidInteraction::new([WATER, LAVA]),
@@ -206,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(),
@@ -362,6 +375,29 @@ fn bounds(at: Vec3) -> Aabb {
Vec3::new(at.x + w, at.y + f64::from(HEIGHT), at.z + w),
)
}
/// The bounding box of an arrow, or a thrown trident, at `at`.
pub fn arrow_bounds(at: Vec3) -> Aabb {
bounds(at)
}
/// The blocks an arrow that moved from `from` to `to` was inside
/// (`Entity.checkInsideBlocks`: the sweep of its box, 16 steps at most), for
/// the blocks that react to it (`entityInside`).
pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec<BlockPos> {
let deflated = bounds(to).deflate(Vec3::new(
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
));
let mut inside = Vec::new();
let _ = sweep_intersected_blocks(from, to, deflated, 1_000_000, |p, iteration| {
if iteration >= 16 {
return false;
}
inside.push(p);
true
});
inside
}
/// `Projectile.lerpRotation`.
fn lerp_rotation(mut old: f32, new: f32) -> f32 {
while new - old < -180.0 {
@@ -423,18 +459,29 @@ 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();
ticker.st.removed = true;
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
if ticker.st.pierce == 0 {
ticker.st.removed = true;
}
}
EntityHit::Refused => ticker.turn_back(),
EntityHit::Deflected => {}
EntityHit::Deflected => {
// The deflection comes before `onHitEntity` adds the entity to
// `piercingIgnoreEntityIds`.
if ticker.st.pierce > 0 {
ticker.st.pierced.pop();
}
}
}
ticker.finish();
p.stuck = st.in_ground;
@@ -464,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();
@@ -512,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 {
@@ -639,7 +696,8 @@ impl Ticker<'_, '_, '_, '_> {
self.st.life = 0;
}
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
/// block (piercing is not modelled).
/// block. A piercing arrow that hits an entity here goes on from it next
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
let block = self.scene.clip(from, to);
let end = block.map_or(to, |b| b.0);
@@ -649,10 +707,16 @@ 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;
}
// canHitEntity: not an entity a piercing arrow has gone through.
if self.st.pierced.contains(who) {
continue;
}
if let Some(at) = self.entity_hit(b, from, end, margin) {
// Sorted by the distance to the entity's position (the
// stable sort keeps the first of equals).
@@ -667,6 +731,15 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position = next;
self.apply_effects_from_blocks(from, next);
if let Some((who, at, _)) = best {
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
// arrow is discarded before it hurts the next one.
if self.st.pierce > 0 {
if self.st.pierced.len() > usize::from(self.st.pierce) {
self.st.removed = true;
return Hit::None;
}
self.st.pierced.push(who);
}
return Hit::Entity { who, at };
}
let Some((at, pos, face)) = block else {
@@ -706,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.
@@ -752,6 +846,7 @@ impl Ticker<'_, '_, '_, '_> {
self.st.shake = 7;
self.p.set_critical(false);
self.st.pierce = 0;
self.st.pierced.clear();
}
/// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`.
fn may_interact(&self) -> bool {
+30 -1
View File
@@ -1,7 +1,7 @@
//! Biome colours from the pinned biome definitions and colormaps
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
use crate::Result;
use horizon_core::{noise::SimplexNoise, random::LegacyRandom};
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
@@ -28,8 +28,13 @@ pub struct BiomeInfo {
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
/// mushroom fields).
pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
/// `temperature_modifier: frozen` (the frozen oceans).
pub frozen: bool,
pub downfall: f32,
pub precipitation: bool,
pub grass: u32,
@@ -42,6 +47,26 @@ pub struct BiomeInfo {
pub fog: Option<u32>,
pub water_fog: u32,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
/// with height above `sea_level + 17`.
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
use horizon_world::worldgen::biome_temperature::{
BiomeTemperatureSettings, TemperatureModifier,
};
BiomeTemperatureSettings {
base_temperature: self.temperature,
modifier: if self.frozen {
TemperatureModifier::Frozen
} else {
TemperatureModifier::None
},
has_precipitation: self.precipitation,
downfall: self.downfall,
}
.height_adjusted(pos, sea_level)
}
}
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
@@ -128,7 +153,11 @@ impl Biomes {
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
@@ -648,6 +648,61 @@ impl Conduit {
}
}
/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ConduitCheck {
/// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`.
pub toggled: Option<bool>,
/// The frame blocks counted, while the conduit is active: `applyEffects`
/// and `updateAndAttackTarget` follow (the attack needs 42 or more).
pub frames: Option<usize>,
}
/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks.
#[derive(Clone, Debug, Default)]
pub struct ConduitServer {
shape: Conduit,
/// `destroyTarget`: the mob id of the enemy being struck.
pub target: Option<u32>,
next_ambient_sound: i64,
}
impl ConduitServer {
/// The `gameTime % 40` part of `serverTick`: the shape is checked again.
pub fn check(
&mut self,
pos: [i32; 3],
game_time: i64,
is_water: impl Fn([i32; 3]) -> bool,
is_frame: impl Fn([i32; 3]) -> bool,
) -> Option<ConduitCheck> {
if game_time % 40 != 0 {
return None;
}
let active = self.shape.update_shape(pos, &is_water, &is_frame);
let toggled = (active != self.shape.is_active).then_some(active);
self.shape.is_active = active;
Some(ConduitCheck {
toggled,
frames: active.then_some(self.shape.effect_blocks.len()),
})
}
/// The sounds of an active conduit at the end of `serverTick`: whether
/// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every
/// 60 to 99 ticks) play.
pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) {
if !self.shape.is_active {
return (false, false);
}
let short = game_time > self.next_ambient_sound;
if short {
self.next_ambient_sound =
game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0));
}
(game_time % 80 == 0, short)
}
}
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Skull {
@@ -832,6 +887,8 @@ pub struct BlockEntityAnims {
pub gateways: FxHashMap<[i32; 3], EndGateway>,
/// The integrated server's copies of the gateways (`portalTick`).
pub gateway_servers: FxHashMap<[i32; 3], EndGateway>,
/// The integrated server's copies of the conduits (`serverTick`).
pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>,
/// The client level's random (conduit particles).
pub random: LegacyRandom,
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
@@ -854,6 +911,7 @@ impl Default for BlockEntityAnims {
pots: FxHashMap::default(),
gateways: FxHashMap::default(),
gateway_servers: FxHashMap::default(),
conduit_servers: FxHashMap::default(),
random: LegacyRandom::new(0x6c6576656c),
kinds: Vec::new(),
present: Vec::new(),
@@ -909,6 +967,7 @@ impl BlockEntityAnims {
self.pots.retain(|p, _| keep(*p));
self.gateways.retain(|p, _| keep(*p));
self.gateway_servers.retain(|p, _| keep(*p));
self.conduit_servers.retain(|p, _| keep(*p));
}
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
+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.)] {
+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]);
+4
View File
@@ -161,6 +161,9 @@ pub struct Chunk {
/// Structure starts reaching into this chunk (`structures.References`):
/// structure id and the start's chunk.
pub structure_refs: Vec<(Arc<str>, (i32, i32))>,
/// The starts generation made in this chunk (`structures.starts`), saved
/// with the state their placement has reached by then.
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
}
impl Chunk {
pub fn empty(x: i32, z: i32) -> Self {
@@ -178,6 +181,7 @@ impl Chunk {
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
structure_starts: Vec::new(),
postprocess: Vec::new(),
saved: None,
entities: Vec::new(),
+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);
+13 -3
View File
@@ -98,6 +98,9 @@ pub enum BlockEntity {
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
@@ -363,6 +366,9 @@ impl BlockEntity {
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -412,6 +418,7 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Campfire(_) => "minecraft:campfire",
}
}
@@ -429,7 +436,8 @@ impl BlockEntity {
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::Skull(_) => Vec::new(),
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
@@ -448,7 +456,8 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::Skull(_) => &[],
| Self::Skull(_)
| Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
@@ -471,7 +480,8 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::Skull(_) => &mut [],
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
+70 -10
View File
@@ -3,7 +3,12 @@
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
use crate::{Result, items::ItemStack, ui::EditBox};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::HashMap,
fs,
path::Path,
sync::{Arc, Mutex},
};
#[derive(Clone, Debug)]
pub struct Tab {
@@ -40,6 +45,17 @@ pub struct Creative {
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
}
/// The grid's stacks for one tab, search text and language.
struct Shown {
tab: usize,
search: String,
/// A display name, so a language change refills the search results.
language: String,
items: Arc<[ItemStack]>,
}
/// Display names from the pinned en_us language file.
#[derive(Default)]
@@ -291,6 +307,7 @@ impl Creative {
},
search_items,
search_index,
shown: Mutex::new(None),
}
}
/// Where a stack comes in the creative tabs (the search tab lists them
@@ -303,6 +320,22 @@ impl Creative {
.position(|s| s.same_item_same_components(stack))
.or_else(|| items.iter().position(|s| s.item == stack.item))
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
})
.collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
}
@@ -384,17 +417,44 @@ impl Creative {
})
.collect()
}
/// Items shown by the selected tab (the search tab filters by `search`).
pub fn items(&self, names: &Names) -> Vec<ItemStack> {
let tab = self.tab();
if tab.search {
return self
.search_results(&self.search.value, names)
/// Items shown by the selected tab (the search tab filters by `search`),
/// worked out again only when the tab, the search or the language changed.
pub fn items(&self, names: &Names) -> Arc<[ItemStack]> {
let tab = self.selected.min(self.tabs.len() - 1);
let search = if self.tab().search {
self.search.value.as_str()
} else {
""
};
let language = names.display_get("itemGroup.search").unwrap_or_default();
let mut shown = self.shown.lock().unwrap_or_else(|e| e.into_inner());
if let Some(s) = shown.as_ref()
&& s.tab == tab
&& s.search == search
&& s.language == language
{
return s.items.clone();
}
let items: Arc<[ItemStack]> = if self.tab().search {
self.search_results(search, names)
.into_iter()
.map(|i| self.search_items[i].clone())
.collect();
}
tab.items.clone()
.collect()
} else {
self.tab().items.as_slice().into()
};
*shown = Some(Shown {
tab,
search: search.to_owned(),
language,
items: items.clone(),
});
items
}
/// Drop the shown items, so the next `items` works them out again: a
/// reload can rename items without changing the Search label.
pub fn forget_items(&self) {
*self.shown.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn rows(&self, names: &Names) -> usize {
self.items(names).len().div_ceil(9)
+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| {
+66 -9
View File
@@ -95,6 +95,15 @@ impl ItemEntity {
self.health -= fire_damage as i32;
}
}
// CactusBlock.entityInside: `entity.hurt(cactus, 1.0)`. An item has no
// hurt cooldown, so it loses 1 health every tick it touches a cactus
// (5 health: gone in 5 ticks). Fire resistance does not help;
// `ItemStack.canBeHurtBy` only skips it for a damage_resistant stack.
if crate::motion::environment::cactus_contact(world, registry, self.bounds())
&& !items.resists_cactus(&self.stack)
{
self.health -= 1;
}
ItemEnvironmentTick {
alive: self.health > 0,
lava_hurt,
@@ -385,8 +394,29 @@ pub struct ExperienceOrb {
pub value: u32,
pub age: u32,
pub on_ground: bool,
/// `ExperienceOrb.count`: orbs merged into this one; each pickup gives
/// `value` once and takes one off.
pub count: u32,
/// `Entity.tickCount`, which paces `ExperienceOrb.scanForMerges`.
pub ticks: u32,
}
impl ExperienceOrb {
pub fn bounds(&self) -> Aabb {
let h = 0.25;
Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 0.5,
self.position.z + h,
),
)
}
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
/// the same value and ids a multiple of 40 apart.
pub fn can_merge(&self, id: u32, value: u32) -> bool {
self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0
}
/// ExperienceOrb.getExperienceValue: the largest orb size not above `value`.
pub fn split(value: u32) -> u32 {
[2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1]
@@ -416,7 +446,9 @@ impl ExperienceOrb {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
next_float: &mut dyn FnMut() -> f32,
) -> bool {
self.ticks += 1;
self.previous = self.position;
let block = world.block(
self.position.x.floor() as i32,
@@ -432,6 +464,14 @@ impl ExperienceOrb {
} else {
self.velocity.y -= 0.03;
}
if facts.get(block).fluid == Fluid::Lava {
// ExperienceOrb.tick: lava throws the orb back out.
self.velocity = Vec3::new(
f64::from((next_float() - next_float()) * 0.2),
f64::from(0.2f32),
f64::from((next_float() - next_float()) * 0.2),
);
}
if let Some(p) = player {
let d = [
(p[0] - self.position.x) / 8.0,
@@ -449,15 +489,7 @@ impl ExperienceOrb {
);
}
}
let h = 0.25;
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 + 0.5,
self.position.z + h,
),
);
let bounds = self.bounds();
let mut scene = SceneCollisions {
world,
binding: collision,
@@ -494,6 +526,31 @@ impl ExperienceOrb {
self.age < 6000
}
}
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
/// adding their counts and keeping the smaller age.
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
let mut removed = vec![false; orbs.len()];
for i in 0..orbs.len() {
if removed[i] || orbs[i].ticks % 20 != 1 {
continue;
}
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
for j in 0..orbs.len() {
if j != i
&& !removed[j]
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
&& orbs[j].bounds().intersects(area)
{
orbs[i].count += orbs[j].count;
orbs[i].age = orbs[i].age.min(orbs[j].age);
removed[j] = true;
}
}
}
let mut removed = removed.into_iter();
orbs.retain(|_| !removed.next().unwrap_or(false));
}
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
#[derive(Clone, Copy, Debug, Default)]
pub struct Experience {
+27 -3
View File
@@ -166,12 +166,23 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d >= end { return 1.0; }
return (d - start) / (end - start);
}
// 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);
// Undo stage_light's 16-bit quantization before adding local texels. Otherwise
// different stages can fall on opposite sides of a nearest-sampling boundary.
let origin = round(packed_origin * size);
let extent = max(round(sprite_size * size), vec2(1.0));
let local = min(floor(fract(uv) * extent), extent - vec2(1.0));
return (origin + local + vec2(0.5)) / size;
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = in.wrap + fract(in.uv) * globals.crumbling_sprite.zw;
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
if in.overlay == 9u || in.overlay == 10u {
@@ -208,9 +219,22 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
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.WATER_MASK: depth only (the pipeline masks the colour off).
+504 -44
View File
@@ -180,7 +180,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("Silent", m.silent),
("NoGravity", m.no_gravity),
("Glowing", m.glowing),
("Invulnerable", m.invulnerable_flag),
] {
if on {
map.insert(key.into(), Tag::Byte(1));
@@ -188,6 +187,26 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
);
map.insert(
"LeftHanded".into(),
Tag::Byte(i8::from(m.extra.left_handed)),
);
// Keys of older formats that structure templates still carry.
for key in [
"HandItems",
"ArmorItems",
"HandDropChances",
"ArmorDropChances",
"Dimension",
"HurtByTimestamp",
"Leashed",
] {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
if m.ticks_frozen > 0 {
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
} else {
@@ -197,7 +216,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("LeftHanded", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
(
"Brain",
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
@@ -209,7 +228,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
// Only breedable animals have an age; a loaded creature that was saved without
// one (a frog) stays without.
let aged = raw.is_none_or(|r| r.get("Age").is_some());
if m.kind.category == "creature" && aged {
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
@@ -225,7 +244,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);
@@ -241,10 +304,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 {
@@ -254,17 +313,30 @@ 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:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
if id == "minecraft:piglin_brute" {
map.remove(&"CannotHunt".into());
}
map.insert(
"IsImmuneToZombification".into(),
Tag::Byte(i8::from(m.extra.flag2)),
);
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
if id == "minecraft:piglin" {
// InventoryCarrier.writeInventoryToTag.
map.insert(
"Inventory".into(),
Tag::List(
m.extra
.inventory
.iter()
.map(crate::save::item_to_nbt)
.collect(),
),
);
}
}
"minecraft:horse"
| "minecraft:donkey"
@@ -275,6 +347,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()));
}
@@ -357,6 +433,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"minecraft:bat" => {
map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag)));
}
"minecraft:turtle" => {
map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag)));
if let Some(home) = m.extra.home {
map.insert("home_pos".into(), Tag::IntArray(home.to_vec()));
}
}
"minecraft:creaking" => {
// home_pos: the heart it is bound to.
if let Some(home) = m.extra.home {
@@ -479,10 +561,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
)])
});
}
"minecraft:zombie" => {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
for (key, value) in [
("CanBreakDoors", Tag::Byte(0)),
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
] {
@@ -507,6 +587,28 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
_ => {}
}
// Zombie.addAdditionalSaveData: CanBreakDoors.
if crate::mobs::doors::is_zombie(m.species()) {
map.insert(
"CanBreakDoors".into(),
Tag::Byte(i8::from(m.extra.can_break_doors)),
);
}
if matches!(
id,
"minecraft:zombie"
| "minecraft:husk"
| "minecraft:drowned"
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:piglin"
| "minecraft:zoglin"
) {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
}
if let Some(a) = m.extra.attributes.as_deref() {
put_attributes(map, m, a);
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -589,6 +691,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
tag
}
/// `AttributeMap.pack` for the attributes with a base or permanent modifier
/// finalizeSpawn set, over the saved list.
fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) {
use crate::mobs::finalize::{self, Attr, Op};
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
for attr in Attr::ALL {
let modifiers: Vec<Tag> = a
.modifiers
.iter()
.filter(|x| x.attr == attr)
.map(|x| {
let op = match x.op {
Op::AddValue => "add_value",
Op::AddMultipliedBase => "add_multiplied_base",
Op::AddMultipliedTotal => "add_multiplied_total",
};
Tag::compound([
("id", string(x.id)),
("amount", Tag::Double(x.amount.to_bits())),
("operation", string(op)),
])
})
.collect();
if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) {
continue;
}
let id = format!("minecraft:{}", attr.name());
let mut entry = vec![
("id", string(&id)),
("base", Tag::Double(finalize::base_value(m, attr).to_bits())),
];
if !modifiers.is_empty() {
entry.push(("modifiers", Tag::List(modifiers)));
}
let entry = Tag::compound(entry);
match list
.iter_mut()
.find(|t| text(t.get("id")).as_deref() == Some(id.as_str()))
{
Some(t) => *t = entry,
None => list.push(entry),
}
}
map.insert("attributes".into(), Tag::List(list));
}
/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones.
pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
if m.extra.memories.is_empty() {
return;
}
let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for &(name, value, ttl) in &m.extra.memories {
let value = match name {
"hunted_recently" => Tag::Byte(value as i8),
"home" => match m.extra.home {
Some(p) => Tag::compound([
("dimension", string(dimension)),
("pos", Tag::IntArray(p.to_vec())),
]),
None => continue,
},
_ => Tag::Int(value as i32),
};
let mut memory = vec![("value", value)];
if ttl != i64::MAX {
memory.push(("ttl", Tag::Long(ttl)));
}
memories.insert(
format!("minecraft:{name}").as_str().into(),
Tag::compound(memory),
);
}
map.insert(
"Brain".into(),
Tag::compound([("memories", Tag::Compound(memories))]),
);
}
/// `CustomName`: a text component saved as a string or a `{text: ..}` compound.
fn custom_name(tag: &Tag) -> Option<String> {
match tag.get("CustomName")? {
@@ -608,6 +793,22 @@ pub fn name_text(tag: &Tag) -> Text {
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
let Some(Tag::List(attrs)) = tag.get("attributes") else {
return None;
};
let range = attrs
.iter()
.find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?;
let Some(Tag::List(modifiers)) = range.get("modifiers") else {
return None;
};
let bonus = modifiers
.iter()
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
float(bonus.get("amount"))
}
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
@@ -633,7 +834,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
}
}
if let Some(bonus) = spawn_bonus(tag) {
use crate::mobs::finalize::{Attr, Op, add_modifier};
let id = "minecraft:random_spawn_bonus";
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") {
@@ -674,7 +886,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
if m.kind.category == "creature" {
m.extra.left_handed = flag(tag, "LeftHanded");
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
@@ -706,6 +919,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
) {
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
if m.species() == crate::mobs::Species::Piglin
&& let Some(Tag::List(items)) = tag.get("Inventory")
{
// InventoryCarrier.readInventoryFromTag (eight slots).
m.extra.inventory = items
.iter()
.filter_map(crate::save::item_from_nbt)
.take(8)
.collect();
}
}
if matches!(
m.species(),
@@ -758,20 +981,45 @@ 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::Wolf {
let health = m.health;
@@ -782,14 +1030,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(),
@@ -847,6 +1087,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
{
crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]);
}
if m.species() == crate::mobs::Species::Turtle {
// Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`.
m.extra.flag = flag(tag, "has_egg");
m.extra.home = Some(match tag.get("home_pos") {
Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]],
_ => [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
});
}
if m.species() == crate::mobs::Species::Wither {
m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32;
}
@@ -925,6 +1177,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
crate::mobs::finalize::set_baby(&mut m, true);
}
// Zombie.readAdditionalSaveData: setCanBreakDoors.
if crate::mobs::doors::is_zombie(m.species()) {
crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors"));
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mob saved mid-death finishes dying; one saved dead is dropped.
@@ -1043,18 +1299,18 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
("Health", Tag::Short(5)),
("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)),
("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)),
("Count", Tag::Int(1)),
("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)),
] {
put(&mut tag, key, value);
}
tag
}
/// One Horizon orb per counted orb (vanilla merges orbs of equal value).
/// One orb with its merged `Count`.
pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
let Some(position) = position(tag) else {
return Vec::new();
};
let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize;
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
id: 0,
position,
@@ -1063,8 +1319,10 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
value: number(tag.get("Value")).unwrap_or(1).max(1) as u32,
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
on_ground: flag(tag, "OnGround"),
count,
ticks: 0,
};
vec![orb; count]
vec![orb]
}
/// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow;
@@ -1913,7 +2171,7 @@ mod tests {
assert_eq!(number(tag.get("Health")), Some(5));
}
#[test]
fn orbs_expand_their_count() {
fn orbs_keep_their_count() {
let o = ExperienceOrb {
id: 0,
position: Vec3::new(1.0, 2.0, 3.0),
@@ -1922,12 +2180,14 @@ mod tests {
value: 7,
age: 3,
on_ground: false,
count: 3,
ticks: 0,
};
let mut tag = orb_to_nbt(&o, [0; 4]);
put(&mut tag, "Count", Tag::Int(3));
let tag = orb_to_nbt(&o, [0; 4]);
let back = orbs_from_nbt(&tag);
assert_eq!(back.len(), 3);
assert_eq!(back.len(), 1);
assert_eq!(back[0].value, 7);
assert_eq!(back[0].count, 3);
}
}
@@ -1992,12 +2252,39 @@ fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
}
}
const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"];
/// `Salmon.Variant` names, by id.
const SALMON_SIZES: [&str; 3] = ["small", "medium", "large"];
/// `TropicalFish.Pattern` names in enum order: the six small patterns, then
/// the six large ones (the order of `extra.variant`).
const TROPICAL_PATTERNS: [&str; 12] = [
"kob",
"sunstreak",
"snooper",
"dasher",
"brinely",
"spotty",
"flopper",
"stripey",
"glitter",
"blockfish",
"betty",
"clayfish",
];
/// `Bucketable.saveDefaultDataToBucketTag`, with the age an axolotl and a
/// tadpole add (`saveToBucketTag`).
pub fn bucket_data(m: &Mob) -> Tag {
let mut data = Tag::compound([
("Age", Tag::Int(m.extra.age)),
("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))),
("Health", Tag::Float(m.health.to_bits())),
]);
let mut data = Tag::compound([("Health", Tag::Float(m.health.to_bits()))]);
if matches!(
m.species(),
crate::mobs::Species::Axolotl | crate::mobs::Species::Tadpole
) {
put(&mut data, "Age", Tag::Int(m.extra.age));
put(
&mut data,
"AgeLocked",
Tag::Byte(i8::from(m.extra.age_locked)),
);
}
for (key, on) in [
("NoAI", m.no_ai),
("Silent", m.silent),
@@ -2027,6 +2314,29 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
.unwrap_or(&"lucy");
data.set_other("minecraft:axolotl/variant", Some(string(variant)));
}
// Salmon.saveToBucketTag: the size.
if m.species() == crate::mobs::Species::Salmon {
let size = SALMON_SIZES
.get(usize::from(m.extra.variant))
.unwrap_or(&"medium");
data.set_other("minecraft:salmon/size", Some(string(size)));
}
// TropicalFish.saveToBucketTag: the pattern and both colours.
if m.species() == crate::mobs::Species::TropicalFish {
let dye = |i: u8| crate::mobs::render::DYE_NAMES[usize::from(i) & 15];
let pattern = TROPICAL_PATTERNS
.get(usize::from(m.extra.variant))
.unwrap_or(&"kob");
data.set_other("minecraft:tropical_fish/pattern", Some(string(pattern)));
data.set_other(
"minecraft:tropical_fish/base_color",
Some(string(dye(m.extra.variant2))),
);
data.set_other(
"minecraft:tropical_fish/pattern_color",
Some(string(dye(m.extra.variant3))),
);
}
data
}
pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
@@ -2077,6 +2387,39 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
{
m.extra.variant = id as u8;
}
// Salmon.applyImplicitComponents: the size.
if m.species() == crate::mobs::Species::Salmon
&& let Some(name) = text(components.other("minecraft:salmon/size"))
&& let Some(id) = SALMON_SIZES
.iter()
.position(|&v| v == name.trim_start_matches("minecraft:"))
{
m.extra.variant = id as u8;
}
// TropicalFish.applyImplicitComponent: the pattern and both colours.
if m.species() == crate::mobs::Species::TropicalFish {
let name = |key| text(components.other(key));
let dye = |key| {
name(key).and_then(|n| {
crate::mobs::render::DYE_NAMES
.iter()
.position(|&v| v == n.trim_start_matches("minecraft:"))
})
};
if let Some(id) = name("minecraft:tropical_fish/pattern").and_then(|n| {
TROPICAL_PATTERNS
.iter()
.position(|&v| v == n.trim_start_matches("minecraft:"))
}) {
m.extra.variant = id as u8;
}
if let Some(id) = dye("minecraft:tropical_fish/base_color") {
m.extra.variant2 = id as u8;
}
if let Some(id) = dye("minecraft:tropical_fish/pattern_color") {
m.extra.variant3 = id as u8;
}
}
}
#[cfg(test)]
@@ -2287,6 +2630,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)]
@@ -35,6 +35,18 @@ impl Damage {
..Self::default()
}
}
/// A block that starts to fall: anvils hurt, and a brushable block
/// breaks where it lands (BrushableBlock.tick: disableDrop).
pub fn falling(block: &str) -> Self {
if block.ends_with("anvil") {
Self::anvil()
} else {
Self {
cancel_drop: block.starts_with("minecraft:suspicious_"),
..Self::default()
}
}
}
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
Self {
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
@@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_brushable_blocks_break_where_they_land() {
assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop);
assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop);
assert!(!Damage::falling("minecraft:gravel").cancel_drop);
assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities);
}
#[test]
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
let damage = Damage::anvil();
File diff suppressed because it is too large. Load diff
+6
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",
@@ -707,9 +710,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!(
@@ -453,6 +453,11 @@ impl Game<'_> {
}
// ArmorStand.swapItem.
match held {
// A player with infinite materials puts a copy of one on an empty
// slot and keeps the stack.
Some(h) if self.creative && current.is_none() => {
self.armor_stands[i].equipment[slot] = Some(ItemStack { count: 1, ..h });
}
Some(h) if h.count > 1 => {
if current.is_some() {
return true;
@@ -5,10 +5,14 @@
//! [`Game::block_anim`] and [`Game::chest_lid`].
use super::Game;
use horizon_client::{
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
block_entity_anim::{
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
containers::BlockEntity,
effects::EffectInstance,
mobs::{Damage, DamageKind},
particles::{ParticleOptions, ParticleType},
save::number,
skins,
@@ -16,6 +20,7 @@ use horizon_client::{
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_world::nbt::Tag;
@@ -218,9 +223,12 @@ impl Game<'_> {
/// The client tickers of the loaded animated block entities, once per
/// client tick (`ClientLevel.tickBlockEntities`).
pub(super) fn tick_block_entity_anims(&mut self) {
// A server has no window, but its gateways and conduits still run
// their server tickers; a client of one only animates what it is sent.
if self.remote.is_none() {
self.tick_conduit_servers();
}
let Some(r) = &self.renderer else {
// A server has no window, but its gateways still run their
// server ticker.
if self.remote.is_none() {
self.tick_gateway_servers();
}
@@ -337,6 +345,7 @@ impl Game<'_> {
}
}
Ticker::Conduit => {
let target = self.conduit_target(pos);
let world = &self.world;
let facts = self.res.facts();
let registry = self.pack.assets.registry;
@@ -356,7 +365,7 @@ impl Game<'_> {
time,
is_water,
is_frame,
None,
target,
self.particles.random(),
);
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
@@ -383,6 +392,248 @@ impl Game<'_> {
}
self.be_anims.present = positions;
}
/// `ConduitBlockEntity.serverTick` of every conduit in the chunks that
/// tick blocks, with or without a renderer. The conduits are looked for
/// when the shape is checked, every 40 ticks (they have no block entity
/// of their own here).
fn tick_conduit_servers(&mut self) {
if self.world.game_time % 40 == 0 {
self.find_conduits();
}
let mut positions: Vec<[i32; 3]> = self.be_anims.conduit_servers.keys().copied().collect();
positions.sort_unstable();
for pos in positions {
self.tick_conduit_server(pos);
}
}
/// Sets `be_anims.conduit_servers` to the conduits in the
/// chunks that tick blocks, keeping the state of those already known.
fn find_conduits(&mut self) {
let levels = self.tickets.levels();
let kinds = &self.be_anims.kinds;
let is_conduit = |id: u32| matches!(kinds.get(id as usize), Some(Some(Ticker::Conduit)));
let mut found = Vec::new();
for (&(cx, cz), chunk) in &self.world.chunks {
if !levels.block_ticking_at(cx * 16, cz * 16) {
continue;
}
for (si, section) in chunk.sections.iter().enumerate() {
if !section.blocks.maybe_has(is_conduit) {
continue;
}
for i in 0..4096 {
if is_conduit(section.get(i)) {
found.push([
cx * 16 + (i & 15) as i32,
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
cz * 16 + ((i >> 4) & 15) as i32,
]);
}
}
}
}
let mut old = std::mem::take(&mut self.be_anims.conduit_servers);
for pos in found {
let known = old.remove(&pos).unwrap_or_default();
self.be_anims.conduit_servers.insert(pos, known);
}
}
/// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk
/// that ticks blocks: the shape check every 40 ticks with its sounds, the
/// power for players in range, the hurt for an enemy, then the ambient
/// sounds.
fn tick_conduit_server(&mut self, pos: [i32; 3]) {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
if self.be_anims.ticker(state) != Some(Ticker::Conduit) {
// Broken since the chunks were searched.
self.be_anims.conduit_servers.remove(&pos);
return;
}
if !self.tickets.levels().block_ticking_at(pos[0], pos[2])
|| !self.border.contains(f64::from(pos[0]), f64::from(pos[2]))
{
return;
}
let time = self.world.game_time;
let mut conduit = self
.be_anims
.conduit_servers
.remove(&pos)
.unwrap_or_default();
let check = {
let world = &self.world;
let facts = self.res.facts();
let registry = self.pack.assets.registry;
let frames = self
.res
.items()
.block_tags
.get("minecraft:conduit_effect_block");
// Level.isWaterAt: the fluid at the position is water.
let is_water = |p: [i32; 3]| {
facts.get(world.block(p[0], p[1], p[2])).fluid
== horizon_client::blocks::Fluid::Water
};
let is_frame = |p: [i32; 3]| {
registry
.state(world.block(p[0], p[1], p[2]))
.is_some_and(|s| frames.is_some_and(|f| f.contains(&s.name)))
};
conduit.check(pos, time, is_water, is_frame)
};
let centre = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
if let Some(check) = check {
if let Some(active) = check.toggled {
let sound = if active {
"minecraft:block.conduit.activate"
} else {
"minecraft:block.conduit.deactivate"
};
self.audio.play(sound, Some(centre), 1.0, 1.0);
}
if let Some(frames) = check.frames {
self.conduit_apply_effects(pos, frames);
self.conduit_attack(&mut conduit, pos, frames >= 42);
}
}
let (ambient, short) = conduit.ambient(time, &mut self.world.random);
if ambient {
self.audio
.play("minecraft:block.conduit.ambient", Some(centre), 1.0, 1.0);
}
if short {
self.audio.play(
"minecraft:block.conduit.ambient_short",
Some(centre),
1.0,
1.0,
);
}
self.be_anims.conduit_servers.insert(pos, conduit);
}
/// `ConduitBlockEntity.applyEffects`: conduit power for every player
/// in water or rain within `frames / 7 * 16` blocks.
fn conduit_apply_effects(&mut self, pos: [i32; 3], frames: usize) {
let range = (frames / 7 * 16) as i64;
self.for_each_player(|g| {
let Some(p) = g.player.as_ref().filter(|p| !p.health.dead_or_dying()) else {
return;
};
if g.spectator() {
return;
}
let at = p.position;
let in_water = p.fluids.was_touching_water;
let top = at.y + f64::from(p.dimensions.height);
// BlockPos.closerThan: the squared distance under range squared.
let d = [at.x, at.y, at.z]
.map(|v| v.floor() as i64)
.iter()
.zip(pos)
.map(|(a, b)| (a - i64::from(b)).pow(2))
.sum::<i64>();
if d >= range * range || !(in_water || g.in_rain(at, top)) {
return;
}
let power = EffectInstance {
ambient: true,
..EffectInstance::new("conduit_power", 260, 0)
};
g.potions.effects.add(power);
});
}
/// `Entity.isInRain`: rain at the block position or at the top of the box.
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
let p = BlockPos::new(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
);
let up = BlockPos::new(p.x, top.floor() as i32, p.z);
self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up))
}
/// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts
/// (42 frame blocks) its target stays until it dies or leaves the 8
/// blocks, else a random enemy in water or rain is chosen; the target
/// takes 4 magic damage.
fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) {
conduit.target = match conduit.target {
_ if !hunting => None,
None => self.conduit_new_target(pos),
Some(id) => self
.mobs
.iter()
.find(|m| m.id == id && !m.removed)
.filter(|m| {
let at = [m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i64);
m.alive()
&& at
.iter()
.zip(pos)
.map(|(a, b)| (a - i64::from(b)).pow(2))
.sum::<i64>()
< 64
})
.map(|m| m.id),
};
let Some(m) = conduit
.target
.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id))
else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
m.damage(4.0, Damage::of(DamageKind::Magic));
self.audio
.play("minecraft:block.conduit.attack.target", Some(at), 1.0, 1.0);
}
/// `ConduitBlockEntity.selectNewTarget`: one of the enemies in water or
/// rain whose box meets the conduit's block inflated by 8.
fn conduit_new_target(&mut self, pos: [i32; 3]) -> Option<u32> {
let [x, y, z] = pos.map(f64::from);
let area = Aabb::new(
Vec3::new(x - 8.0, y - 8.0, z - 8.0),
Vec3::new(x + 9.0, y + 9.0, z + 9.0),
);
let near: Vec<(u32, bool, Vec3, f64)> = self
.mobs
.iter()
.filter(|m| m.hostile() && !m.removed && m.bounds().intersects(area))
.map(|m| {
let top = m.position.y + f64::from(m.height());
(m.id, m.fluids.in_water, m.position, top)
})
.collect();
let candidates: Vec<u32> = near
.into_iter()
.filter(|&(_, in_water, at, top)| in_water || self.in_rain(at, top))
.map(|(id, ..)| id)
.collect();
if candidates.is_empty() {
return None;
}
let pick = self
.world
.random
.next_int_bound(candidates.len() as i32)
.unwrap_or(0);
Some(candidates[pick as usize])
}
/// The conduit's `destroyTarget` as the client draws it.
fn conduit_target(&self, pos: [i32; 3]) -> Option<ConduitTarget> {
let id = self.be_anims.conduit_servers.get(&pos)?.target?;
let m = self.mobs.iter().find(|m| m.id == id && !m.removed)?;
Some(ConduitTarget {
position: [m.position.x, m.position.y, m.position.z],
eye_y: m.eye_y(),
width: m.width(),
height: m.height(),
})
}
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
/// gateways a server keeps a copy of, in block-ticking chunks
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
@@ -10,6 +10,7 @@ use horizon_client::{
block_entities::{Pos, Ticking},
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
};
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
@@ -56,6 +57,7 @@ impl Game<'_> {
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
_ => {}
}
@@ -94,6 +96,7 @@ impl Game<'_> {
| "minecraft:beehive"
| "minecraft:bee_nest"
| "minecraft:brewing_stand"
| "minecraft:daylight_detector"
// Lit, cookTick; unlit, cooldownTick.
| "minecraft:campfire"
| "minecraft:soul_campfire" => Ticking::Yes,
@@ -117,6 +120,16 @@ impl Game<'_> {
let levels = self.tickets.levels();
move |x, z| levels.block_ticking_at(x, z)
}
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
fn tick_daylight_detector(&mut self, pos: Pos) {
// It updates every 20 game ticks (the behaviour checks this too).
if self.world.game_time % 20 != 0 {
return;
}
let at = BlockPos::new(pos.0, pos.1, pos.2);
self.with_level(|b, level, ticks| b.tick_daylight_detector(level, ticks, at));
let _ = self.process_level_events();
}
/// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and
/// its block's `lit` following it.
fn tick_furnace(&mut self, pos: Pos) -> Result<()> {
+2 -2
View File
@@ -55,7 +55,7 @@ pub(super) struct Boat {
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
pub(super) const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -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();
+6 -3
View File
@@ -1009,9 +1009,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);
};
+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,53 @@
//! `CampfireBlock.useItemOn`: an item in `#minecraft:douses_campfires` (the
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
impl Game<'_> {
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
pub(super) fn use_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
) -> Result<bool> {
let Some(tool) = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
}) else {
return Ok(false);
};
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
else {
return Ok(false);
};
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
// then the block change game event from the player.
if self.lan_acting.is_none() {
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
for _ in 0..20 {
make_campfire_smoke(&mut self.particles, pos, signal, true);
}
}
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), None);
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = registry.resolve(&state) {
self.level_set(pos, unlit)?;
}
let used = tool.clone();
self.damage_held(1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &used);
Ok(true)
}
}
+1
View File
@@ -782,6 +782,7 @@ impl Game<'_> {
max_snow_accumulation_height: r.int("max_snow_accumulation_height"),
spawn_mobs: r.bool("spawn_mobs"),
spawn_monsters: r.bool("spawn_monsters"),
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
}
}
/// Apply rules mirrored in other state (natural regeneration, difficulty).
@@ -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) {
@@ -33,5 +37,209 @@ impl Game<'_> {
}
}
self.eye_particles();
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]);
}
+36
View File
@@ -10,6 +10,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 +22,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 +36,10 @@ 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>,
/// 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.
pub(super) charged_exploders: Vec<u32>,
/// The player's fall distance before this tick's movement (as of the
/// end of the last one).
fall_before: f64,
@@ -62,6 +71,14 @@ 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]) {
@@ -90,6 +107,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 {
@@ -107,10 +133,12 @@ impl Game<'_> {
fall_location,
fall_distance,
attacker,
direct,
});
self.combat.last_damage = now;
}
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.
@@ -220,6 +248,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))
{
@@ -244,15 +273,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])
}
}
+80 -36
View File
@@ -541,8 +541,7 @@ impl Game<'_> {
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
@@ -1697,6 +1696,8 @@ impl Game<'_> {
value: 0,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
});
name = Text::translate("entity.minecraft.experience_orb", &[]);
}
@@ -1885,11 +1886,12 @@ impl Game<'_> {
}
}
}
let Some((_, new)) = horizon_client::save::decode_block_entity(&tag) else {
let Some((_, mut new)) = horizon_client::save::decode_block_entity(&tag) else {
return false;
};
let rolled = self.take_loot_table(key, &tag, &mut new);
let after = horizon_client::save::encode_block_entity(key, &new, &name);
if after == before {
if after == before && !rolled {
return false;
}
self.world.block_entities.insert(key, new);
@@ -1897,6 +1899,79 @@ impl Game<'_> {
let _ = self.world.set(pos[0], pos[1], pos[2], state);
true
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
/// container (`setblock`, `data`): an empty container with a `LootTable`
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
/// the roll is kept with its chunk, so that the container saves, and
/// `data get` shows, its table and seed until its items change. A tag
/// without one drops what the container had. True when it had a table.
pub(super) fn take_loot_table(
&mut self,
key: (i32, i32, i32),
tag: &horizon_world::nbt::Tag,
entity: &mut horizon_client::containers::BlockEntity,
) -> bool {
use horizon_client::{containers::BlockEntity, save, structure_blocks};
let table = save::text(tag.get("LootTable"));
let rolled = match (table, entity) {
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
let loot = structure_blocks::loot_data(&self.options.resources);
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
let biome = self.biome_name_at(at).unwrap_or_default();
Some(structure_blocks::roll_container(
slots,
loot.as_deref(),
table,
structure_blocks::loot_seed(tag),
key,
&biome,
))
}
_ => None,
};
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
return false;
};
let held = |c: &horizon_client::chunk::Chunk| {
c.saved
.as_ref()
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
};
if rolled.is_none() && !held(chunk) {
return false;
}
let chunk = std::sync::Arc::make_mut(chunk);
let mut extras = chunk
.saved
.take()
.map(std::sync::Arc::unwrap_or_clone)
.unwrap_or_default();
extras.rolled_loot.retain(|r| r.pos != key);
let taken = rolled.is_some();
extras.rolled_loot.extend(rolled);
chunk.saved = Some(std::sync::Arc::new(extras));
taken
}
/// The loot table and seed a container still holding its roll saves
/// (`RolledLoot::restore`), in place of its items.
pub(super) fn restore_loot_table(
&self,
key: (i32, i32, i32),
tag: &mut horizon_world::nbt::Tag,
) {
let horizon_world::nbt::Tag::Compound(map) = tag else {
return;
};
let rolled = self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key));
if let Some(r) = rolled {
r.restore(map);
}
}
fn cmd_fill(&mut self, ctx: &Context) -> R<i32> {
let a = self.block_pos_arg(ctx, "from")?;
let b = self.block_pos_arg(ctx, "to")?;
@@ -2593,38 +2668,7 @@ impl Game<'_> {
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
let s = self.pack.assets.registry.state(state)?;
let name = s.name.strip_prefix("minecraft:")?;
Some(match name {
"blast_furnace" => "minecraft:armorer",
"smoker" => "minecraft:butcher",
"cartography_table" => "minecraft:cartographer",
"brewing_stand" => "minecraft:cleric",
"composter" => "minecraft:farmer",
"barrel" => "minecraft:fisherman",
"fletching_table" => "minecraft:fletcher",
"cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => {
"minecraft:leatherworker"
}
"lectern" => "minecraft:librarian",
"stonecutter" => "minecraft:mason",
"loom" => "minecraft:shepherd",
"smithing_table" => "minecraft:toolsmith",
"grindstone" => "minecraft:weaponsmith",
"bell" => "minecraft:meeting",
"beehive" => "minecraft:beehive",
"bee_nest" => "minecraft:bee_nest",
"nether_portal" => "minecraft:nether_portal",
"lodestone" => "minecraft:lodestone",
"test_instance_block" => "minecraft:test_instance",
n if n.ends_with("_bed") => {
if s.properties.get("part").map(String::as_str) != Some("head") {
return None;
}
"minecraft:home"
}
n if n.ends_with("lightning_rod") => "minecraft:lightning_rod",
_ => return None,
})
horizon_client::save::poi_type(s).map(|(kind, _)| kind)
}
/// `LocateCommand.locatePoi`: `PoiManager.findClosestWithType` within
/// 256 blocks of the source, over the loaded chunks.
@@ -286,6 +286,7 @@ impl Game<'_> {
if let Tag::Compound(c) = &mut tag {
c.remove(&"keepPacked".into());
}
self.restore_loot_table(key, &mut tag);
Ok(tag)
}
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
@@ -296,7 +297,8 @@ impl Game<'_> {
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
DataRef::Block(p) => {
let key = (p[0], p[1], p[2]);
if let Some((_, new)) = save::decode_block_entity(&tag) {
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
@@ -91,7 +91,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 +159,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,
@@ -145,9 +145,42 @@ impl Game<'_> {
.iter()
.find(|m| m.id == id)
.map_or(0.0, |m| m.eye_y() - m.position.y),
_ => 0.0,
// ArmorStand.getDefaultDimensions: a marker's are empty, a small
// stand's are BABY_DIMENSIONS, whose eye height is 0.9875F.
Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) {
Some(a) if a.marker => 0.0,
Some(a) if a.small => f64::from(0.9875f32),
_ => self.type_eye_height(&self.target_type(t)),
},
// Entity.getEyeHeight: the type's dimensions.
_ => self.type_eye_height(&self.target_type(t)),
}
}
/// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`,
/// read once: a float, widened as `Entity.getEyeY` does.
fn type_eye_height(&self, ty: &str) -> f64 {
static EYE_HEIGHTS: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
std::sync::OnceLock::new();
EYE_HEIGHTS
.get_or_init(|| {
std::fs::read(self.options.resources.join("entity-types.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
v.as_object().map(|o| {
o.iter()
.filter_map(|(k, t)| {
Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32)))
})
.collect()
})
})
.unwrap_or_default()
})
.get(ty)
.copied()
.unwrap_or(0.0)
}
/// EntityAnchorArgument.Anchor.apply.
pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] {
match s.entity {
@@ -467,9 +500,10 @@ impl Game<'_> {
"positioned" if c.has("as") => targets(self)?
.into_iter()
.filter_map(|t| {
// withPosition keeps the anchor (only `positioned <pos>`
// resets it to the feet).
Some(Source {
pos: self.target_position(t)?,
eyes: false,
..s
})
})
@@ -606,6 +640,72 @@ 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(i) => self
.creatures
.projectiles
.get(i)
.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()) => {
@@ -807,10 +907,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);
@@ -244,7 +244,11 @@ impl Game<'_> {
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
while self.pregen_runs_here() {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
while self.pregen_runs_here()
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
{
let now = Instant::now();
if now >= until {
break;
@@ -120,6 +120,9 @@ impl Game<'_> {
tracker.track(&holder(chunk_text(x, z)), i32::from(changed));
}
}
if add {
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
}
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
let verb = if add { "added" } else { "removed" };
let (message, total) = tracker.message(
@@ -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);
}
}
_ => {}
}
}
}
}
@@ -49,26 +49,12 @@ impl Game<'_> {
let mut tracker = Tracker::default();
for &t in &targets {
let h = holder(self.target_name(t));
// ServerRecipeBook.addRecipes/removeRecipes count recipe book
// displays (a transmute recipe has one per material count).
let mut n = 0;
// ServerPlayer.awardRecipes / resetRecipes: the displays changed.
let index = &self.res.stations().recipe_index;
n += if give {
let (count, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
self.recipe_unlocked(&unlocked);
count as i32
} else {
let removed = self.level.recipe_book.remove_recipes(index, &recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
};
// ServerPlayer.awardRecipes / resetRecipes on the target's own
// ServerRecipeBook: a guest's is swapped in for the call, and the
// packets go to its client.
let n = self
.for_player(t, |g| g.change_recipes(give, &recipes))
.unwrap_or(0);
tracker.track(&h, n);
}
let verb = if give { "give" } else { "take" };
@@ -92,6 +78,27 @@ impl Game<'_> {
Ok(total)
}
/// ServerRecipeBook.addRecipes/removeRecipes for the player in place: how
/// many recipe book displays changed (a transmute recipe has one per
/// material count).
fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 {
let index = &self.res.stations().recipe_index;
if give {
let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes);
self.recipe_unlocked(&unlocked);
return count as i32;
}
let removed = self.level.recipe_book.remove_recipes(index, recipes);
let count = removed.len() as i32;
// A network player's client drops them from its book.
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
count
}
// ---- /damage ----------------------------------------------------------------
fn damage_kind(ty: &str) -> DamageKind {
@@ -108,6 +115,7 @@ impl Game<'_> {
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
"dry_out" => DamageKind::DryOut,
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
@@ -133,27 +141,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,
@@ -294,9 +312,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 {
@@ -320,8 +347,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(
@@ -598,8 +643,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();
@@ -5,6 +5,7 @@
use super::{Game, commands::compute::ComputeContext};
use horizon_client::{
items::ItemStack,
mobs::villager,
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{position::BlockPos, random::RandomSource};
@@ -86,6 +87,82 @@ impl Game<'_> {
self.composter_add(pos, &layers);
(stack.count > 0).then_some(stack)
}
/// `WorkAtComposter.compostItems` for the villager at `mob`: a full
/// composter is emptied, then up to 20 wheat or beetroot seeds go in
/// (keeping 10 of each kind) until the composter reaches level 7. Level
/// event 1500 plays once, at the end.
pub(super) fn villager_compost_items(&mut self, mob: usize, pos: [i32; 3]) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let Some(mut before) = self.composter_level(pos) else {
return;
};
if before == 8 {
self.with_level(|b, level, ticks| b.composter_extract(level, ticks, at));
let _ = self.process_level_events();
before = 0;
}
let mut remaining = 20;
let mut amounts: Vec<(std::sync::Arc<str>, i32)> = Vec::new();
let slots = villager::ext(&self.mobs[mob]).map_or(0, |e| e.inventory.len());
'slots: for slot in (0..slots).rev() {
if remaining <= 0 {
break;
}
let stack = villager::ext_mut(&mut self.mobs[mob]).inventory[slot].clone();
if !matches!(
&*stack.item,
"minecraft:wheat_seeds" | "minecraft:beetroot_seeds"
) {
continue;
}
let known = amounts.iter().position(|(item, _)| *item == stack.item);
let total = known.map_or(0, |k| amounts[k].1) + stack.count as i32;
match known {
Some(k) => amounts[k].1 = total,
None => amounts.push((stack.item.clone(), total)),
}
let uses = (total - 10).min(remaining).min(stack.count as i32);
if uses <= 0 {
continue;
}
remaining -= uses;
for _ in 0..uses {
self.villager_insert_item(mob, slot, &stack, pos);
if self.composter_level(pos) == Some(7) {
break 'slots;
}
}
}
let after = self.composter_level(pos).unwrap_or(before);
self.with_level(|b, level, ticks| {
b.composter_fill_effects(level, ticks, at, after != before)
});
let _ = self.process_level_events();
}
/// `ComposterBlock.insertItem` with a villager's stack: below level 7 a
/// compostable item is used up and may add a layer.
fn villager_insert_item(&mut self, mob: usize, slot: usize, stack: &ItemStack, pos: [i32; 3]) {
if self.composter_level(pos).is_none_or(|level| level >= 7) {
return;
}
let Some(layers) = self.compostable(stack) else {
return;
};
let context = ComputeContext {
block: Some(pos),
target: None,
};
let layers = self.provider_int(&layers, &context, 0).unwrap_or(0);
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.composter_layer(level, ticks, at, layers));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
let _ = self.process_level_events();
let inventory = &mut villager::ext_mut(&mut self.mobs[mob]).inventory;
inventory[slot].count -= 1;
if inventory[slot].count == 0 {
inventory.remove(slot);
}
}
/// `ComposterBlock.handleFill` (level event 1500) on the client: the
/// fill sound and ten composter particles above the compost.
pub(super) fn composter_fill_event(&mut self, pos: BlockPos, success: bool) {
+10
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,
@@ -628,6 +628,9 @@ impl Game<'_> {
// The port, or the game rules' search and number boxes.
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
@@ -875,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",
@@ -1080,6 +1086,9 @@ impl Game<'_> {
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
let mut fangs_vertices = Vec::new();
self.fangs_geometry(glam::DVec3::ZERO, 0.5, &mut fangs_vertices);
status["fangs_vertices"] = json!(fangs_vertices.len());
status["maps"] = json!(self.maps_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
+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(
[
+526 -80
View File
@@ -18,6 +18,7 @@ use horizon_client::{
spawning::{self, BiomeFacts},
},
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3, traverse_blocks},
@@ -193,6 +194,7 @@ impl Game<'_> {
velocity: p.velocity,
yaw: self.yaw,
hero: self.potions.effects.get("hero_of_the_village").is_some(),
effects: self.potions.effects.list.iter().map(|e| e.id).collect(),
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
@@ -429,6 +431,12 @@ impl Game<'_> {
),
}
}
/// What the generator finalizes this level's structure mobs with.
pub(super) fn share_spawn_conditions(&self) {
if let Some(terrain) = self.end.locator() {
terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time);
}
}
/// `ServerLevel.getCurrentDifficultyAt`.
pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty {
use horizon_world::food::Difficulty;
@@ -497,6 +505,8 @@ impl Game<'_> {
self.died.insert(m.id);
self.mob_died(&m);
}
// Their victims have been looted: no charged creeper is waiting any more.
self.combat.charged_exploders.clear();
let uuid = self.level.player_uuid;
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
let mut events = Vec::new();
@@ -523,6 +533,8 @@ impl Game<'_> {
_ => Vec::new(),
};
let mut guest_events = Vec::new();
// The mobs whose bodies go this tick: (id, feet, width, height).
let mut gone = Vec::new();
let level_random;
{
// The level random, lent to the mobs (brain behaviours draw from
@@ -595,6 +607,11 @@ impl Game<'_> {
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
gone.push((m.id, m.position, width, height));
}
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
@@ -653,6 +670,12 @@ impl Game<'_> {
}
self.mobs.append(&mut mobs);
self.died = died;
// ... and entity event 60 goes out as it is: the poof.
for (id, at, width, height) in gone {
let at = [at.x, at.y, at.z];
self.lan_entity_event(id as i32, 60, at);
self.death_poof(at, width, height);
}
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
@@ -679,6 +702,22 @@ impl Game<'_> {
self.handle_mob_events(queued)?;
Ok(())
}
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) {
let options = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Poof,
);
for _ in 0..20 {
let r = self.particles.level_random();
let xa = r.next_gaussian() * 0.02;
let ya = r.next_gaussian() * 0.02;
let za = r.next_gaussian() * 0.02;
let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0;
let y = at[1] + height * r.next_double() - ya * 10.0;
let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0;
self.particles.add(&options, [x, y, z], [xa, ya, za]);
}
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
let killer = self.last_hurt_by_player.remove(&m.id);
@@ -779,7 +818,17 @@ impl Game<'_> {
.mobs
.iter()
.find(|o| o.id == id)
.map(|o| drops::loot_entity(o, None)),
.map(|o| drops::loot_entity(o, None))
// A charged creeper's blast kills after the creeper left the list.
.or_else(|| {
self.combat
.charged_exploders
.contains(&id)
.then(|| LootEntity {
powered: true,
..LootEntity::new("minecraft:creeper")
})
}),
None => None,
};
let direct = match d.kind {
@@ -815,12 +864,12 @@ impl Game<'_> {
world_seed: self.level.seed,
};
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
if skulls
&& let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker)
&& let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id)
{
if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) {
// Creeper.droppedSkulls: one head per charged explosion.
creeper.extra.charged = false;
self.combat.charged_exploders.retain(|c| *c != id);
if let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) {
creeper.extra.charged = false;
}
}
let raider = horizon_client::mobs::raider::is_raider(m.species());
for d in items {
@@ -838,9 +887,51 @@ impl Game<'_> {
}
}
}
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
/// sequence with the mob as `this` (a chicken's egg depends on its
/// variant); the loot data of the game's resources decides, and without it
/// the items' table engine (no entity) rolls it.
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
use horizon_client::mobs::drops;
use horizon_world::loot::EntityLootParams;
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
let this = self
.mobs
.iter()
.find(|m| m.id == mob)
.map(|m| drops::loot_entity(m, None));
let stacks = match (loot, this) {
(Some(loot), Some(this)) if loot.has_table(table) => {
let params = EntityLootParams {
this,
..EntityLootParams::default()
};
let seed = self.level.seed;
match loot.random_sequence(table) {
Some(id) => {
let random = self.rand_sequences.get(id, seed);
loot.roll_entity(table, random, &params)
.iter()
.filter(|s| !s.is_empty())
.map(from_loot)
.collect()
}
None => Vec::new(),
}
}
_ => self
.res
.items()
.loot_table(table, false, None, &mut self.world.random),
};
for d in stacks {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
}
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
@@ -889,6 +980,21 @@ impl Game<'_> {
}
}
}
MobEvent::Shift { target, delta } => {
let d = Vec3::new(delta[0], delta[1], delta[2]);
match target {
Who::Player => {
if let Some(p) = self.player.as_mut() {
p.position = p.position.add_vector(d);
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.position = m.position.add_vector(d);
}
}
}
}
MobEvent::Wololo { mob } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
// DyeColor.RED.
@@ -902,16 +1008,24 @@ impl Game<'_> {
owner,
} => self.add_fangs(at, yaw, delay, owner),
MobEvent::Shoot(..) => {}
MobEvent::ExplosionSource { mob, charged } => {
self.combat.explosion_source = Some(mob);
if charged {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let source_mob = self
.mobs
.iter()
.filter(|m| {
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) < 1.0
})
.map(|m| m.id)
.next();
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
.iter()
.filter(|m| {
(m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2)
< 1.0
})
.map(|m| m.id)
.next()
});
if let Some(id) = source_mob {
self.note_attacker("minecraft:explosion", id);
}
@@ -942,6 +1056,9 @@ impl Game<'_> {
volume,
pitch,
);
} else {
// Left for the end of the tick.
t.outbox.push(event);
}
}
}
@@ -1012,6 +1129,11 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
if p.kind == ProjectileKind::LlamaSpit {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
}
self.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
@@ -1044,6 +1166,40 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
self.process_level_events()?;
}
MobEvent::BreakDoor { pos } => {
// BreakDoorGoal.tick: removeBlock, then level events 1021
// and 2001 with the state that is there now.
let air = self.world.air;
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3));
self.process_level_events()?;
self.level_event_at(1021, pos, 0);
let left = self.world.block(pos[0], pos[1], pos[2]);
self.level_event_at(2001, pos, left.0 as i32);
}
MobEvent::DestroyProgress { mob, pos, progress } => {
self.destroy_block_progress(mob as i32, pos, progress);
// ServerLevel.destroyBlockProgress: every player nearby sees it.
if let Some(host) = self.lan.as_ref() {
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
horizon_protocol::packets::play::BlockDestruction {
id: mob as i32,
pos,
progress: if (0..10).contains(&progress) {
progress as u8
} else {
255
},
},
),
None,
);
}
}
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
MobEvent::SetBlockName { pos, name, effect } => {
if effect {
let old = self.world.block(pos[0], pos[1], pos[2]);
@@ -1066,7 +1222,10 @@ impl Game<'_> {
}
MobEvent::BreakBlock { pos, drop } => {
let old = self.world.block(pos[0], pos[1], pos[2]);
self.destroy_particles(pos, old);
// Block.spawnDestroyParticles: fire breaks leave nothing.
if !self.res.facts().get(old).has_class("BaseFireBlock") {
self.destroy_particles(pos, old);
}
if drop {
let drops = self.res.items().block_drops_in(
self.pack.assets.registry,
@@ -1124,7 +1283,7 @@ impl Game<'_> {
self.particles.add(&options, at, velocity);
}
}
MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount),
MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount),
MobEvent::AxolotlSupportPlayer => {
let previous = self
.potions
@@ -1186,6 +1345,10 @@ impl Game<'_> {
self.mob_vibration_particle(from, to, ticks)
}
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
MobEvent::BlockBreakEffect { pos, block } => {
let state = self.world.block(block[0], block[1], block[2]);
self.destroy_particles(pos, state);
}
MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius),
MobEvent::CreakingHurt {
home,
@@ -1196,12 +1359,14 @@ impl Game<'_> {
self.creaking_tear_down(center, spread)
}
MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob),
MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target),
MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other),
MobEvent::VillagerBirth {
parent,
partner,
child,
} => self.villager_birth(parent, partner, child),
MobEvent::WorkAtComposter { mob, pos } => self.villager_work_at_composter(mob, pos),
MobEvent::GolemCheck { mob, needed } => self.villager_golem_check(mob, needed),
MobEvent::Firework { at, color, flight } => {
self.villager_firework(at, color, flight)
@@ -1222,19 +1387,51 @@ impl Game<'_> {
m.ai.aggressive |= aggressive;
}
}
MobEvent::GiftLoot { table, at } => {
let drops =
self.res
.items()
.loot_table(table, false, None, &mut self.world.random);
for d in drops {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
MobEvent::GiftLoot { mob, table, at } => {
// Turtle.ageBoundaryReached: only with mob loot on.
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
{
continue;
}
self.drop_gift_loot(mob, table, at);
}
}
}
Ok(())
}
/// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle
/// per egg, a day from grown, with the egg as its home (the sound, the
/// crumbs and the block's removal came from the level).
pub(super) fn hatch_turtles(
&mut self,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
) {
let eggs = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("eggs"))
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(1);
for i in 0..eggs {
let at = [
f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2,
f64::from(pos[1]),
f64::from(pos[2]) + 0.3,
];
if let Some(id) = self.spawn_mob("minecraft:turtle", at, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.extra.age = -24000;
m.extra.home = Some(pos);
m.yaw = 0.0;
m.body_yaw = 0.0;
m.head_yaw = 0.0;
}
}
}
fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
@@ -1268,6 +1465,13 @@ impl Game<'_> {
m.extra.egg_time = life;
}
}
/// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the
/// `mob_drops` game rule is on.
fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) {
if self.ui.rules.bool("mob_drops") {
self.spawn_orbs(at, amount);
}
}
/// Animal.spawnChildFromBreeding
fn breed(
&mut self,
@@ -1290,6 +1494,8 @@ impl Game<'_> {
.unwrap_or_else(|| parent.clone());
let mut child = parent.clone();
child.baby = true;
// TurtleBreedGoal.breed triggers it without a child.
let turtle = p.species() == Species::Turtle;
if matches!(
(
p.species(),
@@ -1302,7 +1508,7 @@ impl Game<'_> {
) {
child.kind = "minecraft:mule".into();
}
self.trigger_bred(&parent, &partner_view, Some(&child));
self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
}
if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) {
q.extra.age = 6000;
@@ -1314,6 +1520,19 @@ impl Game<'_> {
self.lay_sniffer_egg(&p);
return;
}
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
// experience is left, and only with mob loot on.
if p.species() == Species::Turtle {
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
// split up as `ExperienceOrb.award` does).
if self.ui.rules.bool("mob_drops")
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
{
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
self.add_orb([p.position.x, p.position.y, p.position.z], xp);
}
return;
}
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// A horse and a donkey have a mule.
@@ -1361,7 +1580,7 @@ impl Game<'_> {
}
self.world.random = r;
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
if p.species() == Species::Cat {
@@ -1382,7 +1601,7 @@ impl Game<'_> {
};
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
if p.species() == Species::Wolf {
@@ -1404,7 +1623,7 @@ impl Game<'_> {
};
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
@@ -1420,7 +1639,7 @@ impl Game<'_> {
}
}
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp);
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
}
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
/// mining fatigue III for five minutes unless a stronger or longer one
@@ -1477,11 +1696,34 @@ impl Game<'_> {
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
self.mob_hits_player(amount, from, kind);
}
/// Damage whose causing mob is known (`cause`, or none): not guessed
/// from the mob nearest to `from`.
pub(super) fn mob_hits_player_caused(
&mut self,
amount: f32,
from: [f64; 3],
kind: &str,
cause: Option<u32>,
) {
self.hurt_player_from(amount, Some(from), kind, Some(cause));
}
pub(super) fn mob_hits_player(
&mut self,
amount: f32,
from: [f64; 3],
damage_type: &str,
) -> bool {
self.hurt_player_from(amount, Some(from), damage_type, None)
}
/// `hurtServer` on the player. `from` is the source's position (none: no
/// direction), `known`: the causing mob when it is given (`/damage`),
/// else the melee attacker is the mob nearest to `from`.
pub(super) fn hurt_player_from(
&mut self,
amount: f32,
from: Option<[f64; 3]>,
damage_type: &str,
known: Option<Option<u32>>,
) -> bool {
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -1494,37 +1736,44 @@ impl Game<'_> {
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
let blocking = self.player_blocking(Some(from), damage_type, false);
let blocking = self.player_blocking(from, damage_type, false);
let raised = blocking.is_some();
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
let attacker = (!projectile && damage_type != "minecraft:player_attack")
.then(|| {
self.mobs
.iter()
.filter(|m| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
};
d(a).total_cmp(&d(b))
})
.map(|m| m.id)
})
.flatten();
let attacker = match known {
Some(known) => known,
None => from
.filter(|_| !projectile && damage_type != "minecraft:player_attack")
.and_then(|from| {
self.mobs
.iter()
.filter(|m| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
};
d(a).total_cmp(&d(b))
})
.map(|m| m.id)
}),
};
// The causing entity for the death message.
let owner = self.combat.projectile_owner.take();
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
}
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
// owner) caused the hit, not a player.
let living_cause = attacker.or(owner).is_some();
let mut outcome = horizon_client::health::BlockOutcome::default();
if let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Ok(o) = player.hurt_blocking(
&& let Ok(o) = player.hurt_caused_by(
&self.motion.health,
self.world.health_rules,
id,
amount,
blocking,
living_cause,
)
&& {
outcome = o;
@@ -1534,7 +1783,10 @@ impl Game<'_> {
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
// hurt cooldown (a stronger hit there only adds its difference).
if o.knockback {
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
// No source position: a random direction.
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
(from[0] - player.position.x, from[2] - player.position.z)
});
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
@@ -1542,8 +1794,10 @@ impl Game<'_> {
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
}
}
self.hurt_armor(amount);
self.thorns(from);
// The thorns hurt the entity that caused the hit, if there is one.
if let Some(a) = attacker.or(owner) {
self.thorns(a);
}
}
if raised {
self.after_block(outcome, amount, attacker, projectile);
@@ -1870,6 +2124,33 @@ impl Game<'_> {
None => self.projectile_hit(p, hit),
}
}
/// What an arrow or a thrown trident hits an entity of type `victim`
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
/// (Impaling's `minecraft:damage` on `#sensitive_to_impaling`).
fn shot_damage(&mut self, shot: &mut Projectile, victim: Option<&str>) -> f32 {
if !matches!(shot.kind, ProjectileKind::Trident) {
return arrow_damage(shot, &mut self.world.random) + shot.bonus.damage;
}
let Some(trident) = shot.arrow.as_ref().map(|a| a.pickup_item.clone()) else {
return 8.0;
};
let ctx = horizon_client::enchant::EffectContext {
this: victim,
attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"),
direct_attacker: Some("minecraft:trident"),
damage_type: Some("minecraft:trident"),
items: Some(self.res.items()),
..Default::default()
};
self.res.stations().enchantments.modify_value(
Some(&trident),
"minecraft:damage",
&ctx,
&mut self.world.random,
8.0,
)
}
/// What an arrow's tick asked for: its particles and sounds.
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
@@ -1898,6 +2179,8 @@ impl Game<'_> {
ArrowSound::Splash => "minecraft:entity.generic.splash",
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
ArrowSound::TridentHit => "minecraft:item.trident.hit",
};
self.audio
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
@@ -1994,6 +2277,23 @@ impl Game<'_> {
self.creatures.projectiles.push(arrow);
}
}
/// `TargetBlock.onProjectileHit`: a target block takes its power from
/// where the projectile landed and resets after 20 ticks for an arrow, 8
/// for any other projectile. A player's shot counts as a target hit.
fn target_block_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) {
let block = BlockPos::new(pos[0], pos[1], pos[2]);
let arrow = p.arrow.is_some();
let location = [at.x, at.y, at.z];
let hit = self.with_level(|b, level, ticks| {
b.target_projectile_hit(level, ticks, block, location, face, arrow)
});
if hit.is_some() {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
self.stats.custom("target_hit", 1);
}
}
}
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
self.combat.projectile_owner = match p.owner {
Some(Who::Mob(id)) => Some(id),
@@ -2003,6 +2303,13 @@ impl Game<'_> {
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
Hit::None => return Ok(()),
};
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
// projectile (a wind charge out of the world's top has no face).
if let Hit::Block { pos, face, .. } = hit
&& face != [0; 3]
{
self.target_block_hit(p, pos, at, face);
}
if let Hit::Entity {
who: Who::Mob(id),
at,
@@ -2023,11 +2330,11 @@ impl Game<'_> {
&& id >= super::dragon::SPECIAL
{
let by_player = p.owner == Some(Who::Player);
if let ProjectileKind::Arrow { .. } = p.kind {
if p.arrow.is_some() {
// AbstractArrow.onHitEntity: spent if the part or crystal took
// it, else turned back (a perched dragon sets it alight first).
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let amount = self.shot_damage(&mut arrow, None);
let outcome = match self.hit_special(id, amount, by_player, true) {
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
super::dragon::Hurt::Refused => EntityHit::Refused,
@@ -2048,17 +2355,15 @@ impl Game<'_> {
self.hit_special(id, amount, by_player, false);
return Ok(());
}
if let (
ProjectileKind::Arrow { .. },
Hit::Entity {
if p.arrow.is_some()
&& let Hit::Entity {
who: Who::Mob(id), ..
},
) = (&p.kind, hit)
} = hit
&& let Some(i) = self.carts.iter().position(|c| c.id == id)
{
// AbstractArrow.onHitEntity on a minecart.
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let amount = self.shot_damage(&mut arrow, None);
let burning = p.arrow.as_ref().is_some_and(|a| a.on_fire());
let creative = p.owner == Some(Who::Player) && self.creative;
self.arrow_hits_cart(i, amount, p.velocity, burning, creative);
@@ -2069,7 +2374,7 @@ impl Game<'_> {
// Projectile.onHit: the landing is heard.
self.projectile_event("minecraft:projectile_land", p.owner, atp);
let source_kind = match p.kind {
ProjectileKind::Arrow { .. } => DamageKind::Arrow,
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => DamageKind::Arrow,
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
DamageKind::Fireball
}
@@ -2081,6 +2386,40 @@ impl Game<'_> {
from: Some([p.previous.x, p.previous.y, p.previous.z]),
};
match (&p.kind, hit) {
// ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs),
// dealt whether or not the hurt lands, then it bounces off.
(ProjectileKind::Trident, Hit::Entity { who, .. }) => {
let mut trident = p.clone();
let victim = match who {
Who::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.clone()),
Who::Player => Some("minecraft:player".to_owned()),
};
let amount = self.shot_damage(&mut trident, victim.as_deref());
let hurt = match who {
Who::Player => self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:trident",
),
Who::Mob(id) => self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, source)),
};
let outcome = if hurt {
EntityHit::Hurt
} else {
EntityHit::Refused
};
self.finish_arrow_hit(trident, outcome);
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
(ProjectileKind::Trident, Hit::Block { .. }) => {}
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
@@ -2302,14 +2641,30 @@ impl Game<'_> {
if d2 < 16.0 && !self.creative {
let scale = 1.0 - d2.sqrt() / 4.0;
let t = (f64::from(*ticks) * scale + 0.5) as i32;
if let Some(owner) = self.combat.projectile_owner {
self.note_attacker("minecraft:indirect_magic", owner);
}
match *effect {
"minecraft:instant_damage" => self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
),
"minecraft:instant_damage" => {
// indirectMagic(potion, owner), built where the
// hurt happens: the thrower, or the potion
// itself when it has none. A status effect
// deals no damage, so it records no source.
let owner = self
.combat
.projectile_owner
.filter(|id| self.mob_display_name(*id).is_some());
match owner {
Some(owner) => {
self.note_attacker("minecraft:indirect_magic", owner)
}
None => self.note_direct_entity(
"minecraft:indirect_magic",
Text::translate("entity.minecraft.splash_potion", &[]),
),
}
self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
)
}
e if t > 20 => self.give_player_effect(e, t, *amplifier),
_ => {}
}
@@ -2365,12 +2720,14 @@ impl Game<'_> {
// ShulkerBullet.onHitEntity: 4 damage, then levitation for 10 s.
(ProjectileKind::ShulkerBullet { .. }, Hit::Entity { who, .. }) => match who {
Who::Player => {
if !self.creative {
self.mob_hits_player(
// Only a hit that hurt (`wasHurt`) brings the levitation.
if !self.creative
&& self.mob_hits_player(
4.0,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:mob_projectile",
);
)
{
self.give_player_effect("minecraft:levitation", 200, 0);
}
}
@@ -2379,6 +2736,8 @@ impl Game<'_> {
&& m.damage(4.0, source)
{
m.extra.levitation = 200;
// Shulker.hurtServer: a shulker hit by a bullet.
horizon_client::mobs::shulker::hit_by_bullet(m);
}
}
},
@@ -2460,21 +2819,22 @@ impl Game<'_> {
.as_ref()
.map(|h| h.item.to_string())
.unwrap_or_default();
// Bucketable.bucketMobPickup: a water bucket scoops up a fish, a
// tadpole or an axolotl, with the data its bucket carries.
if item == "minecraft:water_bucket"
&& self.mobs[index].species() == Species::Axolotl
&& self.mobs[index].alive()
&& let Some((bucket, sound)) = bucketable(self.mobs[index].species())
{
let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1);
let mut filled = ItemStack::new(bucket, 1);
filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components(
&self.mobs[index],
)));
self.audio.play(
"minecraft:item.bucket.fill_axolotl",
Some(self.mobs[index].center()),
1.0,
1.0,
);
self.audio
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
self.trigger_item("minecraft:filled_bucket", &filled);
// Leashable.dropLeash, then discard.
let id = self.mobs[index].id;
self.break_leash(id, true, false);
self.mobs[index].removed = true;
if self.creative {
if let Some(rest) = self.give(filled) {
@@ -2528,6 +2888,19 @@ impl Game<'_> {
}
return Ok(true);
}
// Creeper.mobInteract: any item in #creeper_igniters, damaged when it
// is damageable and used up otherwise.
let creeper_igniter = self
.res
.items()
.item_tags
.get("minecraft:creeper_igniters")
.is_some_and(|t| t.contains(&*item));
let damageable = self
.res
.items()
.get(&item)
.is_some_and(|i| i.max_damage > 0);
let m = &mut self.mobs[index];
if !m.alive() {
return Ok(false);
@@ -2543,6 +2916,7 @@ impl Game<'_> {
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
let is_food = m.kind.food.contains(&item);
let baby_turtle = species == Species::Turtle && m.is_baby();
match species {
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
@@ -2768,10 +3142,21 @@ impl Game<'_> {
consume = true;
sound = Some("entity.iron_golem.repair");
}
Species::Creeper if item == "minecraft:flint_and_steel" => {
Species::Creeper if creeper_igniter => {
m.extra.flag = true;
sound = Some("item.flintandsteel.use");
self.damage_held_tool();
// FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE,
// at the creeper's feet with a pitch from its own random.
let event = if item == "minecraft:fire_charge" {
"item.firecharge.use"
} else {
"item.flintandsteel.use"
};
eat = Some((event, 1.0, m.random.next_float() * 0.4 + 0.8));
if damageable {
self.damage_held_tool();
} else {
consume = true;
}
}
// Parrot.mobInteract: parrot food may tame a wild one (one time in
// ten); a parrot never takes it as Animal food (isFood is false).
@@ -2821,6 +3206,14 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
if baby_turtle && !self.mobs[index].is_baby() {
self.handle_mob_events(vec![MobEvent::GiftLoot {
mob: self.mobs[index].id,
table: "minecraft:gameplay/turtle_grow",
at: feet,
}])?;
}
self.swing_arm();
if let Some(s) = sound {
let pitch =
@@ -3028,7 +3421,7 @@ impl Game<'_> {
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = f64::from(self.settings.entity_distance);
let scale = self.view_scale();
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
@@ -3037,9 +3430,16 @@ impl Game<'_> {
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the entity distance option.
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let range = (w + h + w) / 3.0 * 64.0 * scale;
let edge = (w + h + w) / 3.0
* if &*mob.kind.id == "minecraft:player" {
10.0
} else {
1.0
};
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
@@ -3180,6 +3580,30 @@ impl Game<'_> {
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
@@ -3254,6 +3678,26 @@ impl Game<'_> {
}
}
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
/// bucket picks up.
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
let fish = "minecraft:item.bucket.fill_fish";
Some(match species {
Species::Cod => ("minecraft:cod_bucket", fish),
Species::Salmon => ("minecraft:salmon_bucket", fish),
Species::Pufferfish => ("minecraft:pufferfish_bucket", fish),
Species::TropicalFish => ("minecraft:tropical_fish_bucket", fish),
Species::Tadpole => (
"minecraft:tadpole_bucket",
"minecraft:item.bucket.fill_tadpole",
),
Species::Axolotl => (
"minecraft:axolotl_bucket",
"minecraft:item.bucket.fill_axolotl",
),
_ => return None,
})
}
/// Mob.getBaseExperienceReward
/// The damage type id of a Horizon damage kind.
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
@@ -3273,6 +3717,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::InFire => "minecraft:in_fire",
K::Lava => "minecraft:lava",
K::Drown => "minecraft:drown",
K::DryOut => "minecraft:dry_out",
K::Magic => "minecraft:magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
@@ -3298,6 +3743,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
}
}
/// A generated loot stack as a Horizon item stack.
@@ -6,6 +6,7 @@
//! finds the ammo for bows and crossbows.
use super::{Game, shield::OFFHAND_SLOT, view_vector};
use horizon_client::{
enchant::EffectContext,
items::ItemStack,
mobs::Who,
projectiles::{Projectile, ProjectileKind},
@@ -152,6 +153,20 @@ impl Game<'_> {
};
let charged = std::mem::take(&mut bow.data_mut().charged);
bow.tidy();
let weapon = bow.clone();
// AbstractArrow's constructor: `getPiercingCount` of the weapon.
let pierce = self
.res
.stations()
.enchantments
.modify_value(
Some(&weapon),
"minecraft:projectile_piercing",
&EffectContext::default(),
&mut self.world.random,
0.0,
)
.clamp(0.0, 127.0) as u8;
let count = charged.len();
// getShootingPower: a rocket among them slows every shot.
let power = if charged.iter().any(|s| &*s.item == FIREWORK_ROCKET) {
@@ -204,6 +219,7 @@ impl Game<'_> {
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(ammo);
a.set_owner(Some(Who::Player));
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.creatures.projectiles.push(arrow);
+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;
}
+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();
}
}
}
@@ -725,6 +725,11 @@ impl Game<'_> {
match self.world.block_entities.get_mut(&p) {
Some(BlockEntity::Jukebox(j)) => j.record = stack,
Some(BlockEntity::Lectern(l)) => l.book = stack,
Some(BlockEntity::Campfire(c)) => {
if let Some(cell) = c.items.get_mut(slot) {
*cell = stack;
}
}
Some(e) => {
if let Some(cell) = e.slots_mut().get_mut(slot) {
*cell = stack;
@@ -1126,14 +1131,18 @@ impl Game<'_> {
let (p, v) = (b.position, b.velocity);
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
// A merged orb prints once per count, as the oracle does: whether orbs
// merge depends on entity ids, which differ from vanilla's.
for o in &self.orbs {
let (p, v) = (o.position, o.velocity);
out.push(line(
"minecraft:experience_orb",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
));
for _ in 0..o.count.max(1) {
out.push(line(
"minecraft:experience_orb",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
));
}
}
for c in &self.carts {
let (p, v) = (c.position, c.velocity);
+1 -1
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);
+76 -15
View File
@@ -1560,6 +1560,7 @@ impl Game<'_> {
y -= 1.0;
}
self.breath_clouds.push(Breath {
id: 0,
at: [x, y.floor() + 1.0, z],
radius: 5.0,
age: 0,
@@ -1686,10 +1687,12 @@ impl Game<'_> {
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
}
}
// checkCrystals: heal 1 every 10 ticks from the nearest crystal within 32.
// checkCrystals: heal 1 every 10 ticks from the nearest crystal, found
// among those touching the dragon's box grown by 32.
let d = self.dragon.as_mut().expect("dragon");
if d.nearest_crystal.is_some() && d.ticks % 10 == 0 && d.health < MAX_HEALTH {
d.health += 1.0;
// LivingEntity.setHealth clamps to the maximum.
d.health = (d.health + 1.0).min(MAX_HEALTH);
}
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
let p = d.position;
@@ -2022,6 +2025,7 @@ impl Game<'_> {
// assignDirectionalMovement: the direction times the acceleration power 0.1.
let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1);
self.dragon_fireballs.push(DragonFireball {
id: 0,
position: at,
previous: at,
velocity,
@@ -2068,6 +2072,7 @@ impl Game<'_> {
});
if hit_block || hit_player || b.age > 600 {
self.breath_clouds.push(Breath {
id: 0,
at: [next.x, next.y, next.z],
radius: 3.0,
age: 0,
@@ -2152,11 +2157,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);
}
@@ -2197,15 +2205,58 @@ impl Game<'_> {
}
}
pub(super) struct DragonFireball {
position: Vec3,
previous: Vec3,
velocity: Vec3,
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
age: i32,
}
impl DragonFireball {
/// One a server sent (`handleAddEntity`).
pub(super) fn remote(id: u32, at: Vec3, velocity: Vec3) -> Self {
Self {
id,
position: at,
previous: at,
velocity,
age: 0,
}
}
/// `AbstractHurtingProjectile.tick` on a server's client: `applyInertia`
/// first (power 0.1, inertia 0.95 or 0.8 in water), then the move; only
/// the server decides what it hits.
pub(super) fn remote_step(&mut self, water: bool) {
self.previous = self.position;
self.age += 1;
let v = self.velocity;
// Vec3.normalize: a vector shorter than 1e-5 stays zero.
let l = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
let n = if l < 1.0e-5 {
Vec3::new(0.0, 0.0, 0.0)
} else {
Vec3::new(v.x / l, v.y / l, v.z / l)
};
let inertia = f64::from(if water { 0.8f32 } else { 0.95f32 });
let v = Vec3::new(
(v.x + n.x * 0.1) * inertia,
(v.y + n.y * 0.1) * inertia,
(v.z + n.z * 0.1) * inertia,
);
self.velocity = v;
self.position = Vec3::new(
self.position.x + v.x,
self.position.y + v.y,
self.position.z + v.z,
);
}
}
pub(super) struct Breath {
at: [f64; 3],
radius: f32,
age: i32,
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
duration: i32,
from_dragon: bool,
}
@@ -2550,7 +2601,9 @@ impl Game<'_> {
}
pub(super) fn fight_ready(&mut self) -> bool {
self.load_fight();
self.fight.loaded
// spawnExitPortal runs from scanState and the dragon's death, both
// only with players in the fight.
self.fight.loaded && self.fight.players
}
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
self.fight.exit_portal
@@ -3127,16 +3180,24 @@ impl Game<'_> {
let _ = self.activate_exit_portal();
self.fight.dirty = true;
}
/// `EnderDragon.checkCrystals`' search: the nearest crystal within 32
/// blocks on each axis.
/// `EnderDragon.checkCrystals`' search: the nearest crystal whose box
/// meets the dragon's box grown by 32.
fn nearest_crystal_to(&self, p: Vec3) -> Option<usize> {
// The dragon's 16 by 8 box.
let reach = Aabb::new(
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
)
.inflate(Vec3::new(32.0, 32.0, 32.0));
self.crystals
.iter()
.enumerate()
.filter(|(_, c)| {
(c.position.x - p.x).abs() <= 32.0
&& (c.position.y - p.y).abs() <= 32.0
&& (c.position.z - p.z).abs() <= 32.0
// An end crystal's 2 by 2 box.
reach.intersects(Aabb::new(
Vec3::new(c.position.x - 1.0, c.position.y, c.position.z - 1.0),
Vec3::new(c.position.x + 1.0, c.position.y + 2.0, c.position.z + 1.0),
))
})
.min_by(|a, b| {
let da = (a.1.position.x - p.x).powi(2)
+186 -31
View File
@@ -3,10 +3,14 @@
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
//! the armor attribute effects (respiration, depth strider, ...).
//! the armor attribute effects (respiration, depth strider, ...) and Soul
//! Speed's `location_changed` and `tick` effects.
use super::Game;
use horizon_client::{enchant::EffectContext, items::ItemStack};
use horizon_core::random::RandomSource;
use horizon_client::{
enchant::{EffectContext, Enchantments, level_value},
items::ItemStack,
};
use horizon_core::{position::BlockPos, random::RandomSource};
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
@@ -255,23 +259,26 @@ impl Game<'_> {
let _ = self.wind_burst(feet, radius, knockback, false, false);
}
}
/// Thorns on worn armor when a mob at `from` hits the player.
pub(super) fn thorns(&mut self, from: [f64; 3]) {
let attacker = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2)
+ (m.position.y - from[1]).powi(2)
+ (m.position.z - from[2]).powi(2)
};
d(a.1).total_cmp(&d(b.1))
})
.map(|(i, _)| i);
let Some(attacker) = attacker else { return };
/// Thorns on worn armor when the mob `attacker` hits the player: the
/// `affected: attacker` effect of `Enchantment.doPostAttack` hurts the
/// entity that caused the hit.
pub(super) fn thorns(&mut self, attacker: u32) {
let Some(index) = self.mobs.iter().position(|m| m.id == attacker && m.alive()) else {
return;
};
for damage in self.thorns_damage() {
let m = &mut self.mobs[index];
m.hurt(damage, None, &mut self.world.random);
let center = [m.position.x, m.position.y, m.position.z];
self.audio
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
}
}
/// The victim's side of thorns: for each worn piece whose chance comes up,
/// the damage to deal the attacker (`DamageEntity` min 1, max 5) and the
/// piece's wear (`ChangeItemDamage` 2).
pub(super) fn thorns_damage(&mut self) -> Vec<f32> {
let mut hits = Vec::new();
for slot in 0..4 {
let Some(stack) = self.armor[slot].clone() else {
continue;
@@ -299,15 +306,7 @@ impl Game<'_> {
if !ok {
continue;
}
// DamageEntity(min 1, max 5) and ChangeItemDamage(2).
let damage = 1.0 + self.world.random.next_float() * 4.0;
let center = {
let m = &mut self.mobs[attacker];
m.hurt(damage, None, &mut self.world.random);
[m.position.x, m.position.y, m.position.z]
};
self.audio
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
hits.push(1.0 + self.world.random.next_float() * 4.0);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += 2;
if self
@@ -320,6 +319,7 @@ impl Game<'_> {
}
}
}
hits
}
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
pub(super) fn mending(&mut self, amount: u32) -> u32 {
@@ -388,7 +388,9 @@ impl Game<'_> {
}
}
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
/// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the
/// `ammo_use` conditions as the tool, so Infinity only frees plain arrows.
pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) {
let Some(bow) = self.inventory[self.selected].clone() else {
return (0.0, false);
};
@@ -407,7 +409,7 @@ impl Game<'_> {
0.0,
);
let ammo_ctx = EffectContext {
tool: Some("minecraft:arrow"),
tool: Some(ammo),
items: Some(self.res.items()),
..Default::default()
};
@@ -460,6 +462,134 @@ impl Game<'_> {
}
ignite
}
/// Soul Speed (`data/minecraft/enchantment/soul_speed.json`), the parts of
/// `Enchantment.runLocationChangedEffects` and `tick` that file uses. The
/// `location_changed` effects run when the player enters a new block, lands,
/// or its boots change:
/// - the speed and movement efficiency modifiers turn on over a soul speed
/// block (not flying, not riding) and stay on while airborne, then off
/// (`enchantment_active_check`);
/// - the boots lose 1 durability with a 4% chance when on such a block.
///
/// Every fifth tick the player walking on one also gets a soul particle
/// (returned as its position and velocity, for the caller to show or send
/// to the players around) and sometimes a sound. Runs for the window's own
/// player and for each guest on the server (`guest_server_tick`); a remote
/// client leaves it to its server.
pub(super) fn tick_soul_speed(&mut self) -> Option<([f64; 3], [f64; 3])> {
const ID: &str = "minecraft:soul_speed";
let player = self.player.as_mut()?;
if player.client_side {
return None;
}
let level = self.armor[3]
.as_ref()
.map_or(0, |s| s.enchantment_level(ID));
let position = player.position;
let (on_ground, flying) = (player.ground.on_ground, player.flying);
let moved = position.subtract(player.old_position);
let (tick, width) = (player.health.tick_count, player.dimensions.width);
let fx = &mut player.location_effects;
let on_soul = fx.on_soul_speed_block;
let block = BlockPos::containing(position);
let changed_block = fx.block.replace(block) != Some(block);
let landed = std::mem::take(&mut fx.landed);
// Changed boots: `stopLocationBasedEffects`, then the effects run again.
let equipped = std::mem::replace(&mut fx.boots_level, level) != level;
let was_active = fx.applied_level;
let mut active = was_active > 0 && !equipped;
let mut wear = false;
if level > 0 && (changed_block || landed || equipped) {
let riding = self.riding.is_some();
let wanted = !riding && !flying && (on_soul || (active && !on_ground));
active = wanted;
// `random_chance` is the first term of the wear's `all_of`: it
// draws from the level random before the block is looked at.
let roll = self.world.random.next_float() < 0.04;
wear = roll && on_ground && on_soul;
}
fx.applied_level = if active { level } else { 0 };
// `Entity.getKnownMovement` horizontal speed of at least 1.0E-5.
let walking = moved.x * moved.x + moved.z * moved.z >= 1.0e-10;
let tick_effects = level > 0 && !flying && on_ground && walking && on_soul && tick % 5 == 0;
// The attribute effects were applied to the player before: swap them.
if was_active > 0 && (!active || equipped) {
for (attribute, id, _, op) in
soul_speed_modifiers(&self.res.stations().enchantments, was_active)
{
let _ = player.set_modifier(&attribute, &id, 0.0, &op, false);
}
}
if active && (was_active == 0 || equipped) {
for (attribute, id, amount, op) in
soul_speed_modifiers(&self.res.stations().enchantments, level)
{
let _ = player.set_modifier(&attribute, &id, amount, &op, true);
}
}
if wear && !self.creative {
self.wear_boots(1);
}
if level == 0 {
return None;
}
let r = &mut self.world.random;
// `spawn_particles`: in the bounding box, 0.1 above the feet, thrown
// back against the movement (-0.2) and up (0.1).
let particle = tick_effects.then(|| {
let x = position.x + (r.next_double() - 0.5) * f64::from(width);
let z = position.z + (r.next_double() - 0.5) * f64::from(width);
(
[x, position.y + 0.1, z],
[moved.x * -0.2, 0.1, moved.z * -0.2],
)
});
// `play_sound`: its `random_chance` (35%) comes first in its `all_of`,
// so the level random is drawn every tick the boots are worn; volume
// 0.6, pitch 0.6 to 1.0.
if r.next_float() < 0.35 && tick_effects {
let pitch = r.next_float() * 0.4 + 0.6;
self.audio.play(
"minecraft:particle.soul_escape",
Some([position.x, position.y, position.z]),
0.6,
pitch,
);
}
particle
}
/// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`):
/// Unbreaking applies, and they break when worn out.
fn wear_boots(&mut self, amount: u32) {
let Some(stack) = self.armor[3].clone() else {
return;
};
let loss = self.durability_loss(&stack, amount);
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |info| info.max_damage);
if max == 0 {
return;
}
let Some(boots) = self.armor[3].as_mut() else {
return;
};
boots.damage += loss;
if boots.damage >= max {
self.armor[3] = None;
if let Some(eye) = self.eye_position() {
self.audio.play(
"minecraft:entity.item.break",
Some(eye),
0.8,
0.8 + self.world.random.next_float() * 0.4,
);
}
self.apply_equipment();
}
}
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
/// so the same enchantment on two pieces stacks instead of replacing itself.
@@ -483,3 +613,28 @@ impl Game<'_> {
self.potions.enchant_modifiers = wanted;
}
}
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
fn soul_speed_modifiers(
enchantments: &Enchantments,
level: u32,
) -> Vec<(String, String, f64, String)> {
let Some(e) = enchantments.get("minecraft:soul_speed") else {
return Vec::new();
};
e.effects["minecraft:location_changed"][0]["effect"]["effects"]
.as_array()
.into_iter()
.flatten()
.filter(|a| a["type"] == "minecraft:attribute")
.map(|a| {
(
a["attribute"].as_str().unwrap_or("").to_owned(),
format!("{}/feet", a["id"].as_str().unwrap_or("")),
f64::from(level_value(&a["amount"], level)),
a["operation"].as_str().unwrap_or("add_value").to_owned(),
)
})
.collect()
}
+1 -1
View File
@@ -19,7 +19,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;
@@ -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<'_> {
+21 -14
View File
@@ -296,12 +296,14 @@ impl Game<'_> {
if !creative
&& !spectator
&& hurtable
&& let Ok(o) = p.hurt_blocking(
&& let Ok(o) = p.hurt_caused_by(
&self.motion.health,
self.world.health_rules,
id,
amount,
blocking,
// The exploding mob is the causing entity.
exploding.is_some(),
)
{
outcome = o;
@@ -314,9 +316,6 @@ impl Game<'_> {
);
}
}
if outcome.hurt && !creative {
self.hurt_armor(amount);
}
if raised {
self.after_block(outcome, amount, None, false);
}
@@ -391,9 +390,11 @@ impl Game<'_> {
[feet[0] - w, feet[1], feet[2] - w],
[feet[0] + w, feet[1] + h, feet[2] + w],
);
// The exploding mob is the damage source's entity, so victims
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
attacker: None,
attacker: exploding.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
if hurtable && !m.damage(damage(dist, exposure), source) {
@@ -571,16 +572,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).
@@ -597,6 +598,11 @@ 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]);
@@ -620,6 +626,7 @@ impl Game<'_> {
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
decay,
false,
);
for mut stack in drops {
if &*stack.item == "minecraft:air" || stack.count == 0 {
@@ -64,6 +64,25 @@ impl Game<'_> {
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
} else if self
.res
.facts()
.get(block.state)
.name
.starts_with("minecraft:suspicious_")
{
// BrushableBlock.onBrokenAfterFall.
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.level_events.push(LevelEvent::LevelEvent {
id: 2001,
pos: at,
data: block.state.0 as i32,
});
self.level_events.push(LevelEvent::GameEvent {
pos: at,
event: "minecraft:block_destroy",
state: None,
});
}
return;
}
@@ -108,16 +127,10 @@ impl Game<'_> {
} else {
amount
};
let hurt = if let Some(p) = self.player.as_mut()
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(kind)
{
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false)
} else {
false
};
if hurt {
self.hurt_armor(amount);
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
}
}
}
+70 -15
View File
@@ -7,12 +7,13 @@ use std::sync::Arc;
#[derive(Clone, Debug)]
pub(super) struct Fangs {
pos: [f64; 3],
yaw: f32,
pub id: u32,
pub pos: [f64; 3],
pub yaw: f32,
warmup: i32,
life: i32,
pub life: i32,
owner: u32,
started: bool,
pub started: bool,
}
#[derive(Default)]
pub(super) struct FangState {
@@ -21,6 +22,11 @@ pub(super) struct FangState {
}
impl Game<'_> {
pub(super) fn add_fangs(&mut self, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
self.next_entity += 1;
self.insert_fangs(self.next_entity, at, yaw, delay, owner);
}
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
if self.fangs.model.is_none() {
self.fangs.model = horizon_client::mobs::load_model(
&self.options.resources,
@@ -29,6 +35,7 @@ impl Game<'_> {
.map(Arc::new);
}
self.fangs.list.push(Fangs {
id,
pos: at,
yaw,
warmup: delay,
@@ -37,6 +44,30 @@ impl Game<'_> {
started: false,
});
}
/// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack.
pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) {
if event == 4
&& let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id)
{
f.started = true;
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
1.0,
);
}
}
/// `EvokerFangs.tick`: clients only advance animation, never damage or discard.
pub(super) fn tick_remote_fangs(&mut self) {
for f in &mut self.fangs.list {
if f.started {
f.life -= 1;
}
}
}
/// `EvokerFangs.tick`.
pub(super) fn tick_fangs(&mut self) {
let mut list = std::mem::take(&mut self.fangs.list);
@@ -50,7 +81,8 @@ impl Game<'_> {
}
if !f.started {
f.started = true;
self.audio.play(
self.lan_entity_event(f.id as i32, 4, f.pos);
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
@@ -71,26 +103,49 @@ 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);
self.each_guest_hit(bite);
// `AbstractIllager.considersEntityAsAlly`: with no teams, the illager
// tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose
// owner is allied (itself or another illager). Teams are not modelled.
let illager = |s: Species| {
matches!(
s,
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
)
};
let illagers: Vec<u32> = self
.mobs
.iter()
.filter(|m| illager(m.species()))
.map(|m| m.id)
.collect();
// A missing owner hurts everyone (`currentOwner == null`).
let owner_known = illagers.contains(&f.owner);
for m in &mut self.mobs {
let ally = matches!(
m.species(),
Species::Evoker
| Species::Illusioner
| Species::Vex
| Species::Pillager
| Species::Vindicator
| Species::Witch
);
let ally = owner_known
&& (illager(m.species())
|| m.species() == Species::Vex
&& illagers.contains(&(m.extra.counter2 as u32)));
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
m.damage(
6.0,
+2 -8
View File
@@ -372,17 +372,11 @@ impl Game<'_> {
if self.creative || self.spectator() {
return;
}
let hurt = if let Some(p) = self.player.as_mut()
if let Some(p) = self.player.as_mut()
&& !p.health.dead_or_dying()
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
{
p.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false)
} else {
false
};
if hurt {
self.hurt_armor(amount);
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
}
}
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
Loaded 100 of 635 files, more files were not shown because too many files have changed in this diff. Show more