Compare commits

..
Author SHA1 Message Date
levzzzandCodex 9755416a02 Fix Windows path assertions in the launcher toolchain test (#3285)
Compare managed compiler and PATH entries as platform paths, including Windows executable suffixes. Fixes the Windows assertion that blocked the native-capable launcher release.

Validation: pr_ready passed (401 Python and 219 launcher tests), and the release dry run passed tests and packaged smoke checks on Linux, macOS and Windows.

Co-Authored-By: Codex <[email protected]>
2026-10-28 21:42:53 +05:00
rdbt a5fcb49900 Merge pull request #3262 from Domanffe/fix/chat-log-duplicate
Log a chat message once, as [CHAT]
2026-10-28 16:27:04 +00:00
Ludovic Marie 0ca3a5be98 Merge pull request #3077 from Domanffe/test/parity-reach-posix-fixture
🤖 Agent: read the diff at aef4186de: test_parity_reach's fixture records dependencies with `as_posix()`, as the tool does. verify green. Queued for the merger.
2026-10-28 17:19:37 +01:00
Ludovic Marie 8993772de0 Merge pull request #3078 from Domanffe/fix/port-harness-windows-log-path
🤖 Agent: read the diff at 28e3286e4: port_harness also picks up Windows `C:...x.log` paths from javac's "see ..." lines. verify green. Queued for the merger.
2026-10-28 17:00:51 +01:00
Ludovic Marie 6a261a00a5 Merge pull request #2886 from socksprox/fix/launcher-forget-offline-name
🤖 Agent: read the diff at 5096b4110: launcher offline names: Forget also deletes the name's skin and cape files, async skin picks bail if the name was forgotten meanwhile, account rows load-mutate-save, and "New name" rolls a fresh PlayerNNNNN into every game home. Tests included. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:00:45 +01:00
Ludovic Marie f8b83eea51 Merge pull request #3250 from horizon-rust/perf/world-entry-pump-between-ticks
🤖 Agent: verify green; pr_ready on the Mac mini passes (34 affected harnesses, 33 run and pass, lan_integration by name: 7 lan_e2e checks incl. spawned/respawn/portals/forced-levels pass). World entry, 2 workers, RD 12, medians of 3: create 8.05 → 3.57 s, reload 3.72 → 1.42 s to all view columns loaded.
2026-10-28 16:54:43 +01:00
Alexander KrylovandHermes Agent 88398e8bda Log a chat message once
`chat_line` logged `[chat]` and then `Chat::add_message` logged `[CHAT]` for
the same message, so every line appeared twice, and the `[chat]` copy slipped
past the `[CHAT]` filter `log_for_report` uses to keep chat out of an attached
log.

Vanilla's `ChatComponent.logChatMessage` writes the one line `add_message`
already writes, so the `chat_line` call is the leftover.

Fixes #3261

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 18:36:21 +03:00
Ludovic Marie 6d3489d30b Merge pull request #3064 from iLostName/trial-spawner-spawn-egg
🤖 Agent: read the diff at 557a82738: a spawn egg on a trial spawner follows `TrialSpawner.overrideEntityToSpawn` -> `FullConfig.overrideEntity` in the 26.3 source: data reset, normal and ominous configs replaced by `Holder.direct(withSpawning(type))` (saved inline through the new `TrialConfig::encode`, `optionalFieldOf` defaults left out), then `setState(INACTIVE)`; plain spawners unchanged. Round-trip test included. verify and parity green on this head. Queued for the merger.
2026-10-28 16:35:54 +01:00
Ludovic MarieandClaude Opus 5.5 7a84c93843 Server: feed the chunk workers between ticks while players' chunks load
A server thread (single player's, a LAN host's, a dedicated server's) ran
`streaming.update` once a tick, and `schedule_generation` keeps at most
three load or generate jobs per worker in flight. Joining a world was
capped at about six columns a tick (120 a second with two workers) while
the workers idled most of each tick. Vanilla's server runs its chunk
tasks while it waits for the next tick (`MinecraftServer.waitUntilNextTick`).

`pump_pregen`, now `pump_chunks`, already did that for a /pregen task and
force-loaded chunks; it now also pumps while the players' chunks are in
flight, except while the server is paused or a respawn search waits for
a chunk (its area is only asked for by the tick's own update).

Mac mini, --profile fast, offscreen, seed 1234, RD 12, server thread,
medians of 3, all 637 view columns in and meshed once:
  2 workers: create 8.05 -> 3.57 s, reload 3.72 -> 1.42 s
  4 workers: create 4.97 -> 2.16 s, reload 2.23 -> 1.27 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:26:03 +01:00
Ludovic Marie cba52f4e5d Merge pull request #3218 from horizon-rust/fix-server-junction
🤖 Agent: verified: verify, launcher and parity-scope green; local fmt, clippy -D warnings and launcher tests pass. Fixes #3200. Queued for merge.
2026-10-28 16:04:17 +01:00
Ludovic MarieandClaude Opus 5.5 9f1fa0a0a2 launcher: quote server junction paths and show why mklink failed
Creating a server on Windows failed with a bare "mklink /J failed" (#3200).
servers.rs passed the paths to cmd unquoted, so a user folder with `&`, `^`
or `(` broke cmd's parsing; sources.rs already quoted them. Both now use
one sources::junction, which also puts cmd's own message in the error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:15:13 +01:00
Ludovic Marie fe84cc07a1 Merge pull request #3208 from horizon-rust/launcher-sidebar-sponsor
🤖 Agent: verified: verify, launcher and parity-scope green; local cargo fmt, clippy -D warnings and launcher tests pass. Queued for merge.
2026-10-28 14:55:08 +01:00
Ludovic Marie d4e42cf726 Merge pull request #3195 from horizon-rust/feat/launcher-native-mods-window
🤖 Agent: proven with a real launcher GUI run in isolated dirs (Settings → Native mods row, red warning naming veinminer 0.3.0, trust → cached build reused, status line); launcher clippy and 265 tests green; #3194 merged and this now targets main with only its own 5 files. Verify green at 505de825.
2026-10-28 14:51:41 +01:00
Ludovic MarieandClaude Opus 5.5 466512c90e launcher: a Sponsor link in the sidebar footer
The GitHub Sponsors link was only on the patch notes page. Show it beside
Discord and Website in the sidebar footer (heart icon), from links.json's
sponsor entry, translated in fr and ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:44:51 +01:00
Ludovic Marie 4db071e281 Merge pull request #3194 from horizon-rust/feat/launcher-native-mods-page
🤖 Agent: proven with a real launcher run in isolated dirs: registry install of Veinminer's .hnmod into an instance, native-build of that instance's enabled native mods, instance pinned to the build; launcher clippy and tests green after merging main (#3140 and #3189 merged). Verify green at 1f6511d0.
2026-10-28 14:40:43 +01:00
Ludovic Marie 2649c5e506 Merge pull request #3202 from horizon-rust/ci/nightly-cap-12
Nightly: 12 builds a day instead of 6
2026-10-28 14:27:40 +01:00
Ludovic MarieandClaude Opus 5.5 505de825db Merge feat/launcher-native-mods-page (main merged in)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:27:32 +01:00
Ludovic MarieandClaude Opus 5.5 1f6511d0f3 Merge main into feat/launcher-native-mods-page (#3140 and #3189 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:26:02 +01:00
Ludovic MarieandClaude Opus 5.5 1523c118d8 Nightly: 12 builds a day instead of 6
The Team tests fixes on the Nightly and hit the cap of 6 builds a day
(Ludovic, 2026-10-28: raise it to 12).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:21:08 +01:00
Ludovic Marie 9239089b76 Merge pull request #3140 from levzzz5154/feat/native-mod-bundles
🤖 Agent: merging levzzz5154's native-mod registry kind; proven with the real Veinminer .hnmod through registry build, the launcher's install check and a native build that ran (see comment).
2026-10-28 14:20:09 +01:00
Ludovic Marie 281ab9759c Merge pull request #3180 from horizon-rust/refactor/furnace-cooking
🤖 Agent: verify passed at 8b5a27ed3. Move only (crate split B7): the furnace's recipe logic leaves containers as the FurnaceCooking extension trait. Full pr_ready green on this head (1261 tests, parity 69 affected / 0 failed); #2959, #2960 and #3131 still merge cleanly. Ready once parity is green.
2026-10-28 14:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 febad05c78 launcher: French and Russian for the Native mods row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:07:55 +01:00
Ludovic MarieandClaude Opus 5.5 519edfac19 docs: native mods in the launcher window
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:06:21 +01:00
Ludovic Marie 6561df77f6 Merge pull request #3189 from horizon-rust/feat/launcher-native-build
🤖 Agent: merging: native-build compiles horizon-mixins bundles on their pinned Nightly with the launcher's own Rust; proven by building levzzz's Veinminer .hnmod on 83f15fa (580 s cold, 2m46s warm) and /veinminer answering in the built game.
2026-10-28 14:05:32 +01:00
Ludovic MarieandClaude Opus 5.5 cd7f177104 Launcher: a Native mods switch and Build in Settings, with the warning
Settings → Game → Native mods (experimental), off by default. On, Build names the native mods
turned on in the instance Play starts, warns in red that they run with full access to the
computer, and compiles them only from "I trust these mods: build"; the row shows the step while
it runs, the error if it fails, and the build the instance plays. Ported from native-mixins-1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:04:35 +01:00
Ludovic MarieandClaude Opus 5.5 07ab7ccfc1 Merge the native build's measured numbers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 727f6c932a docs/native-mods.md: the measured build times and sizes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 cd1f58ef72 Launcher: native mods from the registry install and build like the others
- native-mod entries (levzzz5154's #3140) are listed as mods and install as
  horizon-native-mods/<id>.hnmod (the folder #3112's inventory reads), turned on in
  horizon-mods.txt, after the .hnmod is checked as the registry checks it.
- native-build with no folder builds the native mods turned on in the instance, and that
  instance then plays the build (the game's own home builds into the Native mods instance).
- Every registry project says who made it; a project not made by Horizon says it is not made
  or endorsed by the Horizon project, with the mod policy's link (playhorizon.dev/mods/policy).
  A native mod says it runs with full access to the computer and which Horizon it builds on.
- Pruning keeps every native build an instance plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:35 +01:00
Ludovic Marie 291aae00e8 Merge pull request #3174 from horizon-rust/ci/nightly-source
🤖 Agent: merging: nightly publishes horizon-source.tar.gz (signed) and tags its commit nightly-source/<date>-<short>; proven by a full dry run of the workflow (run 40686585965) and tests/test_nightly.py.
2026-10-28 13:49:40 +01:00
Ludovic Marie bec8cce82d Merge pull request #3179 from horizon-rust/feat/launcher-rust-toolchain
🤖 Agent: merging: the launcher's portable Rust 1.99.1 (pinned SHA-256 components, own data dir); proven by a real isolated rust install + crate build on the Mac mini and the launcher tests.
2026-10-28 13:49:37 +01:00
Ludovic MarieandClaude Opus 5.5 0ee2425d40 Merge levzzz5154's #3140 (native-mod registry kind, .hnmod validator)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:44:24 +01:00
Ludovic MarieandClaude Opus 5.5 7f3bd901bc Launcher: build native mods on the Nightly they pin, with its own Rust
horizon-launcher native-build FOLDER --trust-native-code compiles mixin bundles (a folder with a
horizon-mixin.toml, or an unpacked .hnmod from the registry) into a game of their own:
- the Horizon commit the mods pin (all must agree; else the installed version's), its source from
  GitHub by commit (Nightlies keep theirs as nightly-source/ tags);
- horizon-mixins at the commit the mods were tested with, built once with the launcher's Rust;
- horizon-mixins apply, then the release's cargo build, with the portable toolchain;
- installed as native-<commit>-<key>, played by the Native mods instance.
Based on native-mixins-1's unmerged work; docs/native-mods.md describes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 d308df6167 Launcher: a portable Rust toolchain of its own, for native mods
Native mods compile the game on the player's computer (levzzz5154: many native mods per build). The
launcher now downloads the official rustc, cargo and rust-std components of Rust 1.99.1 (plus
rust-mingw on Windows, the windows-gnu host that needs no Visual Studio) from static.rust-lang.org,
each pinned by SHA-256, into <launcher data>/rust/, and runs them with its own CARGO_HOME. No rustup,
nothing installed on the system. `horizon-launcher rust status|install|remove`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:32:09 +01:00
Ludovic MarieandClaude Opus 5.5 5ded675a7d Nightly: publish the exact source, and tag its commit for good
The nightly release names its commit, and now also attaches horizon-source.tar.gz (git archive of that
commit, made from git objects in pick and signed with the builds) and tags the commit
nightly-source/<date>-<short>. The dated release and the nightly/build-* branch are pruned after 7 days;
the tag is not, so native mods pinned to a Nightly's commit keep building.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:16:48 +01:00
Ludovic Marie 564162a0b0 Merge pull request #2741 from horizon-rust/refactor/cut-biome-spawn-parsers
🤖 Agent: verify passed at 0f565f4e4. Move only: parse_entries/parse_costs/SpawnCost go from mobs/spawn_selection.rs to biome.rs (re-exported), cutting biome -> mobs (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-28 13:12:01 +01:00
Ludovic Marie 8b5a27ed3f Merge remote-tracking branch 'origin/main' into refactor/furnace-cooking 2026-10-28 13:11:00 +01:00
Hermes AgentandHermes Agent c0505424bd fix(launcher): keep native sources hidden until build integration lands
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:27:51 +05:00
Hermes AgentandHermes Agent 137045e321 feat(mods): validate source-only native mod bundles
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:20:36 +05:00
AlexanderandHermes Agent 28e3286e40 Port harness: read the log a Windows javac error names
named_logs only matched a Posix absolute path (`see /...log`), so on Windows
horizon-prepare's "see C:\...\export.log" matched nothing and its javac
errors were never shown. The pattern now takes either form.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:21:06 +03:00
AlexanderandHermes Agent aef4186de4 Tests: the parity-reach fixture names files the way the tool does
narrow() and its graph key files by Posix path (`crates/a/src/used.rs`), so
a fixture built from str(relative_to) on Windows produced backslashes and all
seven tests errored in setUp. as_posix() in the fixture matches the tool.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:16:41 +03:00
Cosmic b6c936f82b Merge pull request #3058 from kacpherek/perf/cart-signals-only-on-detector-rails
Server lag in new forests: count minecart signals only when a detector rail reads them
2026-10-28 02:52:35 -04:00
Kacpherek 6960b65c62 Keep the cart and its box apart in detector_rail_signal 2026-10-28 07:07:43 +01:00
Kacpherek 5b13af2f75 Count a minecart's comparator signal only when a detector rail reads it
Every with_level built every chest and hopper cart's signal through item lookups. tick_scheduled calls it once per scheduled tick, so freshly generated forests (thousands of leaf ticks) next to mineshaft chest carts spent a large share of the server tick there. detector_rail_signal now counts the cart it reads.
2026-10-28 06:54:33 +01:00
iLostName 557a827383 trial spawner: a spawn egg sets its mob, as overrideEntityToSpawn does
Fixes #3041.
2026-10-28 06:52:26 +01:00
rdbt 56f968e11c Merge pull request #2868 from masa-masa-0314/fix/closed-captions
Subtitles: fix closed captions never appearing
2026-10-28 00:48:18 +00:00
rdbt ae4cea18ef Merge pull request #3004 from feeldev12/feat/slim-skins
feat(client): slim skins wear the slim player model
2026-10-28 00:47:38 +00:00
rdbt 296802a618 Merge pull request #2980 from feeldev12/fix/own-skin-first-person
fix(client): the first-person arm and the player models wear the player's own skin
2026-10-28 00:47:14 +00:00
rdbt a5d3ad165a Merge pull request #2891 from socksprox/t3/clickable-book-links
Books: page click events work, run_command ones included
2026-10-28 00:46:58 +00:00
rdbt 2120ffedb4 Merge pull request #2878 from leifiyoo/riptide-spin-server-countdown
Count down Riptide's spin on a server's copy of a player
2026-10-28 00:46:44 +00:00
rdbt 04e6e21813 Merge pull request #2883 from sirrayi/deps/webpki-advisories
Update rustls-webpki and webpki-roots for RUSTSEC-2026-0382 and -0381
2026-10-28 00:46:15 +00:00
rdbt acee253e1c Merge pull request #2940 from ASOwnerYT/tools/harness-no-vscreen
Harnesses run offscreen on a Linux without vscreen
2026-10-28 00:46:02 +00:00
rdbt 555dce8bb3 Merge pull request #2944 from kacpherek/fix/armor-stand-plates-op-warning
Armor stands press pressure plates; match vanilla's op warning in item tooltips
2026-10-28 00:43:56 +00:00
rdbt 067163ded8 Merge pull request #2968 from kacpherek/fix/block-advancement-triggers
Award the target hit and honey slide advancements
2026-10-28 00:43:49 +00:00
rdbt 704cf97780 Merge pull request #2972 from kacpherek/fix/player-nausea-eyeblossom
Show Nausea on the player: spin the view and draw the swirl
2026-10-28 00:43:30 +00:00
rdbt 007d38fe49 Merge pull request #2822 from sirrayi/fix/telemetry-ipv6-buckets
Telemetry server: count IPv6 clients by /64 in the rate limits
2026-10-28 00:43:14 +00:00
rdbt 43e2ad34c7 Merge pull request #2953 from kacpherek/fix/beacon-christmas-chest-glow-frame
Render beacon beams, Christmas chests and glow frame maps like vanilla
2026-10-28 00:42:39 +00:00
rdbt a6dbc6651f Merge pull request #2837 from iLostName/pack-prompt-keeps-pointer
server packs: the download prompt acts as the screen it is
2026-10-28 00:42:18 +00:00
rdbt 4bb3de29e7 Merge pull request #2931 from elythepreciouscat/feat/vanilla-mouse-settings-26-3
Options: add Java 26.3 mouse settings
2026-10-28 00:41:15 +00:00
rdbt 786f67f77b Merge pull request #2967 from kacpherek/fix/blindness-sky-fog
Apply blindness fog and hide the blocked sky
2026-10-28 00:40:49 +00:00
rdbt 797852c62b Merge pull request #2840 from iLostName/client-skips-loot-tables
remote: a server's client doesn't load the loot tables on its first tick
2026-10-28 00:39:53 +00:00
rdbt 1f8f1055d5 Merge pull request #2852 from levzzz5154/fix/armadillo-roll-ball
A hit rolls the armadillo up into a ball (fixes #2759)
2026-10-28 00:39:36 +00:00
rdbt 0121466ed2 Merge pull request #2943 from kacpherek/fix/2928-mod-recipes-before-world-rest
Skip mod recipe tables until the world resources are loaded
2026-10-28 00:38:21 +00:00
rdbt 55b0545c11 Merge pull request #3017 from DanielGodriNeto/perf/sprite-pixels-shrink
perf(assets): drop the spare capacity of the sprite pixel buffer
2026-10-28 00:37:35 +00:00
rdbt 5e14e309bc comment not needed 2026-10-28 00:37:08 +00:00
rdbt 9e236c7914 Merge pull request #2834 from socksprox/fix/2747-eat-sounds
Play the player's own bites while eating on a remote server
2026-10-28 00:35:59 +00:00
rdbt 2d7fc7f000 Merge pull request #2958 from kacpherek/fix/extended-keybinds
Allow F13-F24 and other extended keys in key bindings
2026-10-28 00:35:39 +00:00
rdbt 644a399389 Merge pull request #2965 from kacpherek/fix/high-contrast-block-option
Enable the High Contrast Block Outline option
2026-10-28 00:34:16 +00:00
rdbt 9e31ce2f3d Merge pull request #2844 from Domanffe/fix/pixelify-cyrillic-o-p
Fonts: Pixelify Sans gets the Cyrillic О and П it was missing
2026-10-28 00:32:49 +00:00
rdbt 66d03b4d1c Merge pull request #2887 from masa-masa-0314/fix/creative-search-reset-on-open
Creative inventory: fix the search box keeping the last search after closing
2026-10-28 00:32:36 +00:00
rdbt 84d5574887 Merge pull request #2956 from gemakfy/fix/creative-f-offhand-crash
Fix creative inventory off-hand swap crash on Key F
2026-10-28 00:32:16 +00:00
Ludovic Marie 4118e026c3 Merge pull request #3014 from horizon-rust/docs/team-merge-rule
🤖 Agent: Merged by Ludovic's AI agent after verify: docs and lists only (Team merge rule, levzzz5154 trusted reviewer, licence holder), Ludovic's request 2026-10-28.
2026-10-28 00:56:21 +01:00
Ludovic MarieandClaude Opus 5.5 51b374121b Team merge rule: ordinary pull requests merge on CI, risky ones get needs-proof; levzzz5154 trusted
The Horizon Team merges ordinary pull requests once verify and parity pass; saves, network, world generation,
launcher updates, the harnesses and big changes wait for a local proof. Also names the licence holder with the
contributors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 00:46:44 +01:00
DanielGodriNeto 9042746704 perf(assets): drop the spare capacity of the sprite pixel buffer 2026-10-27 20:39:24 -03:00
Ludovic Marie 795490271d Merge pull request #2988 from horizon-rust/refactor/be-layer-deps
🤖 Agent: verify passes on this head; full pr_ready green on the Mac mini (1340 tests, parity 89 affected / 0 failed). Move-only step B6 of the crate split (crate-split-1).
2026-10-27 23:27:53 +01:00
feeldev 9fe3ddfec3 feat(client): slim skins wear the slim player model
A skin whose metadata says model slim (PlayerSkin.Model.SLIM) now draws
with PlayerModel's slim layer (3-pixel arms), on other players, the local
player's model, mannequins and the first-person arm. Without a skin, the
profile's default skin (DefaultPlayerSkin.get) keeps its own model, so
Alex and the other slim defaults are slim again.

Fixes #2926
2026-10-27 17:26:01 -05:00
Ludovic MarieandClaude Opus 5.5 7a62c6f4a7 Give the capture test's fixture the pack prompt
capture() now asks the server pack prompt and the dialog; the fixture
gets both, and the prompt's own case: it keeps the pointer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:46:23 +01:00
Ludovic MarieandClaude Opus 5.5 e0bb0881e8 item frames: glow frame map light is block coordinate 210, as vanilla's 15728850
15728850 is 0xF000D2: sky coordinate 240 and block coordinate 210, which
the packed light here stores as 240 | 210 << 8 (208 was 13 * 16).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:36:57 +01:00
Ludovic Marie 4b4950d7c4 Merge branch 'refactor/be-layer-deps' into refactor/furnace-cooking 2026-10-27 22:36:31 +01:00
Ludovic Marie e902b6fc18 Merge remote-tracking branch 'origin/main' into refactor/be-layer-deps 2026-10-27 22:34:05 +01:00
Ludovic MarieandClaude Opus 5.5 5096b41100 Merge main into #2886: offline names need an owner since main
Keep main's owner check (play_as takes the store, Sign out updates
the owner state) with the branch's load-mutate-save and skin cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:22 +01:00
Ludovic MarieandClaude Opus 5.5 46c84a8b24 Merge main into #2891: main has run_command click events since
Keep main's ClickEvent::RunCommand and click_command (now pub(super) for
the book screen) instead of the branch's copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:27:03 +01:00
Ludovic Marie ae82c1b693 Merge remote-tracking branch 'origin/main' into cg24/f2891 2026-10-27 22:26:51 +01:00
Ludovic MarieandClaude Opus 5.5 501e543105 Creative on a server: the off hand's edits reach the server too
CreativeInventoryListener sends every InventoryMenu slot that changes,
the off hand (slot 45) included; only the 36 inventory slots were sent,
so Key F's off-hand stack stayed on this client (single player runs on
its server thread too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:47 +01:00
Ludovic MarieandClaude Opus 5.5 466fef4486 Remote eating: the sound plays for items without consume particles too
Consumable.emitParticlesAndSounds gates only the particles on
hasConsumeParticles; the sound always plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:02 +01:00
Ludovic MarieandClaude Opus 5.5 e34f01715c Merge main into pack-prompt-keeps-pointer
main's draw_toasts takes the F1 flag now; the prompt still hides the
HUD, sign editor and book under it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:22:25 +01:00
Kacpherek ad61c30f46 Award target hit and honey slide advancements 2026-10-27 22:02:19 +01:00
Kacpherek f35ee16412 Apply blindness fog and hide the blocked sky 2026-10-27 22:02:19 +01:00
Kacpherek 5216403562 Enable the high contrast block outline option 2026-10-27 22:02:18 +01:00
Kacpherek 2aea6138b7 Allow extended keyboard keys in bindings 2026-10-27 22:02:17 +01:00
Kacpherek 5b41d9ebf3 Light glow frame maps at 15728850
A map in a glow item frame is drawn at light 15728850 (sky 15, block 13), not full bright, as ItemFrameRenderer does.
2026-10-27 21:59:46 +01:00
Kacpherek 52f3dcc5ef Use christmas chest textures on 24-26 December
Normal and trapped chests use the christmas sprites on 24-26 December, as ChestRenderer does, with the local date read once. minecraft:local_time reads the system time zone instead of UTC, so the Christmas chest item switches on the local date.
2026-10-27 21:59:46 +01:00
Kacpherek 7d99d23aef Fix beacon beam distance and cull at render distance
The beam width used x and y instead of the horizontal distance, so it was wrong for beacons to the north or south. Beams beyond render distance x 16 are skipped, as BeaconRenderer.shouldRender does. A beacon with Levels 0 still draws no beam.
2026-10-27 21:59:46 +01:00
Ludovic Marie 1feb28c045 Merge pull request #2750 from horizon-rust/refactor/horizon-client-input
🤖 Agent: Merged by Ludovic's AI agent (orchestrator), crate split route (c): head 64c174118 (main merged in, no rebase) passed the full tools/pr_ready.py on the Mac mini (2327 workspace tests, 398 Python, launcher 205; parity --changed 130 run, 0 failed) plus command_exec and save_roundtrip --fresh; verify green.
2026-10-27 21:58:06 +01:00
Kacpherek 7f6e8b634e Spin the level view and draw the Nausea swirl for the player
Nausea never reached the camera or the HUD: the swirl input was hard-coded to 0 and the level projection had no portal or Nausea spin. Add vanilla's spin (max of portal and Nausea intensity, scaled by Distortion Effects squared) and the Nausea blend fade (150 in, 20 out). Vanilla: GameRenderer.renderLevel, LocalPlayer.tickSpinningEffect, MobEffects NAUSEA blend.
2026-10-27 21:56:00 +01:00
Ludovic Marie 2e69ac9886 Merge remote-tracking branch 'origin/main' into refactor/be-layer-deps 2026-10-27 21:49:00 +01:00
asownerytandClaude Opus 5.5 c309d331ad Harnesses run offscreen on a Linux without vscreen
Without vscreen, save_roundtrip, world_reopen and spawn_loot stopped on
FileNotFoundError, and lan_e2e's games opened windows on the real
desktop: unfocused, the host paused before its world loaded and waited
at "Connecting to the server..." until wait-ready gave up. They now run
offscreen there, as on macOS, and lan_e2e's games also skip the pause
(as HORIZON_E2E_HEADLESS does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:47:06 +13:00
Kacpherek 7acfc655e8 Match vanilla's op warning on item tooltips
The warning was missing its bold first line and skipped spawn eggs of op-only entities. Vanilla: ItemStack.OP_NBT_WARNING, BlockItem and SpawnEggItem.shouldPrintOpWarning (non-block items never warn).
2026-10-27 21:45:45 +01:00
Kacpherek d485c688d6 Let armor stands press pressure plates and tripwires
A non-marker armor stand is a LivingEntity, so it counts for the mob and weighted plates and trips wires. ArmorStand.isIgnoringBlockTriggers() is only true for markers.
2026-10-27 21:45:44 +01:00
Ludovic Marie 93d77962ee Merge pull request #2936 from horizon-rust/chore/horizon-rust-links
🤖 Agent: Merged by Ludovic's AI agent (orchestrator): links after the move to horizon-rust, the mod index's new address, and the release-notes fix that makes verify pass on main again. pr_ready --quick and the full Python suite pass locally.
2026-10-27 21:43:18 +01:00
Kacpherek 47ca0312df Skip mod recipe tables until the world resources are loaded
On a slow start the title is drawn while the world resources still load. A running mod that reads recipes built its tables every title frame and hit the unloaded resources, which panicked. Fixes #2928.
2026-10-27 21:38:40 +01:00
Ludovic MarieandClaude Opus 5.5 c9fa1c8115 Format the mod registry after the URL change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:31:47 +01:00
Ludovic MarieandClaude Opus 5.5 d2b0347777 Point links at the horizon-rust organization
The repositories moved from ludovic111 to the horizon-rust organization.
GitHub redirects repository URLs but not GitHub Pages, so the mod index
moved to https://horizon-rust.github.io/horizon-mods/: the launcher and
the game read it there. The launcher still accepts icons and files from
the old addresses, which a mirror keeps serving for launchers released
before the move.

Release notes thanked ludovic111 once the repository's owner became the
organization (verify failed on main): the maintainer is now named.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:28:04 +01:00
feeldev 78c028321c fix(client): the first-person arm and the player models wear the player's own skin
The empty first-person arm drew Steve's skin always; it now uses the local
player's skin, as the inventory's model does (PlayerRenderer.renderRightHand
reads AbstractClientPlayer.getSkin). Player models without a downloaded skin
wear their profile's default skin (DefaultPlayerSkin.get), as the player
list does. In single player the integrated server is offline-mode and sends
no textures, so the signed-in account's own profile is fetched from the
services (Minecraft.profileFuture) and gives the local player its skin.

Fixes #2921
2026-10-27 15:21:32 -05:00
Ludovic Marie 64c174118a Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 21:13:23 +01:00
Ludovic Marie f21e09510e Merge pull request #2910 from ludovic111/feat/triage-commands
🤖 Agent: Merged by Ludovic's AI agent (orchestrator) after review: comment commands for the trusted reviewers (/close, /reopen, /label, /unlabel), from main's code only, same list and pause as /nightly.
2026-10-27 21:11:21 +01:00
ElyTheCat 127434c46c Options: add Java 26.3 mouse settings 2026-10-27 23:21:09 +03:30
gemakfy 7b40465f85 Fix creative inventory off-hand swap crash on Key F 2026-10-27 22:38:19 +03:00
Ludovic Marie 27c3559421 Merge pull request #2879 from leifiyoo/fix/keychain-long-secrets
Keep long Microsoft sign-in secrets in several keychain items
2026-10-27 20:14:06 +01:00
Ludovic Marie a91711afaf Merge pull request #2382 from kacpherek/fix/parrot-follow-sit
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said not to wait for his re-review); main is merged in after the crate split, and this head is proven in union/cg19-r5 (clippy, 1695 tests, 121/121 harnesses, mob_ai). Thanks @kacpherek!
2026-10-27 20:06:07 +01:00
Ludovic Marie 4df2461b65 Merge pull request #2408 from kacpherek/fix/bundle-dump-on-use
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Proven in union/cg19-r2 and union/cg19-r3, then merged with main again after the crate split and proven in union/cg19-r5 (clippy, tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 20:05:25 +01:00
Ludovic Marie 35540bda73 Merge pull request #2898 from ludovic111/feat/launcher-mojang-download
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Proven by the real run with Ludovic's own sign-in on the Mac mini (download from Mojang, prepare, play, sign-out refused, keychain clean; see #2892).
2026-10-27 20:01:57 +01:00
Ludovic Marie f66e6a944e Merge pull request #2896 from ludovic111/feat/launcher-owner-screen
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Proven by the real run with Ludovic's own sign-in on the Mac mini (download from Mojang, prepare, play, sign-out refused, keychain clean; see #2892).
2026-10-27 20:01:49 +01:00
Ludovic MarieandClaude Opus 5.5 a34a41e1b5 refactor: move the furnace's recipe logic out of containers
Move-only step B7 of the horizon-client crate split. `impl Furnace`
needed `crafting::Recipes`, the one gameplay module containers still
reached. Its four methods (set_input, recipe_experience, input_clicked,
tick) move unchanged to a new `furnace` module, as the extension trait
`FurnaceCooking` implemented for the Furnace data, with the two helpers
only they use (remainder, total_time). Callers add one `use`.

containers now depends only on modules of the render/world layer
(skins and tab_list included), so it can move into that crate whole.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:59:55 +01:00
Ludovic MarieandClaude Opus 5.5 9861a1ba04 refactor: cut vault, command_block, sculk and brewing loose from gameplay
Move-only step B6 of the horizon-client crate split. These four modules
only reached gameplay modules for small helpers, so after this they depend
only on the render/world layer and can move into its crate whole, impls
included.

- save::float moves to horizon_client_core::nbt_value (save re-exports it).
- mobs::spawner::normalize_id moves to horizon_client_core::identifier
  (spawner re-exports it).
- containers::Providers moves to its own module, providers.rs
  (containers re-exports it).
- vault, command_block and sculk import number/text/float, item_nbt,
  Who and scoreboard from their real homes instead of through save and
  mobs; brewing imports Providers from providers.
- The vault, spawner and trial_spawner harnesses record the client-core
  files the moved helpers now live in.
- villager_brain's `impl Poi` (max_tickets, valid_range) moves next to
  the type in poi_sections, and its two methods become pub.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:59:32 +01:00
Ludovic Marie 26547a5f29 Merge pull request #2860 from ludovic111/refactor/block-entity-data
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). The full pr_ready ran on this exact merge with main: clippy -D warnings, 1340 tests, 369 Python tests, 78 affected harnesses (77 passed, 0 failed), and lan_e2e --check chest passes in-frame and on the server thread. Verify is green here. Crate split, priority 1.
2026-10-27 19:58:54 +01:00
Ludovic Marie d9ab241406 Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 19:48:28 +01:00
Ludovic Marie 41c2c2205c Merge pull request #2892 from ludovic111/feat/launcher-owner-gate
🤖 Agent: owner gate (Ludovic's decision 2026-10-27). Unit and CLI/MCP tests pass, and the real run with Ludovic's sign-in passed (Play as lu4ovic; after sign-out Play refused; keychain clean). Results posted on the PR. #2896 and #2898 are stacked on it.
2026-10-27 19:46:54 +01:00
Ludovic Marie 43134f4e41 Merge branch 'feat/launcher-owner-screen' into feat/launcher-mojang-download 2026-10-27 19:32:19 +01:00
Ludovic Marie 85c8276e06 Merge branch 'feat/launcher-owner-gate' into feat/launcher-owner-screen 2026-10-27 19:32:19 +01:00
Ludovic Marie 95bf2811fc Merge remote-tracking branch 'origin/main' into feat/launcher-owner-gate 2026-10-27 19:32:19 +01:00
Ludovic MarieandClaude Sonnet 5.5 d87b081c34 Trusted reviewers triage on GitHub: /close, /reopen, /label, /unlabel
One list (.github/trusted-reviewers.txt) now governs /nightly and the triage commands.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 19:21:08 +01:00
Ludovic Marie cf62fe270b Merge pull request #2679 from kacpherek/feat/spectator-menu
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:14:58 +01:00
Ludovic MarieandClaude Opus 5.5 b784b15b8a Merge main into fix/parrot-follow-sit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:11:21 +01:00
Ludovic MarieandClaude Opus 5.5 38b508e5a7 Merge main into fix/bundle-dump-on-use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:11:21 +01:00
Ludovic Marie de24a16ef4 Merge pull request #2349 from kacpherek/fix/campfire-douse-client
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:11:16 +01:00
Ludovic Marie 9b53749263 Merge pull request #2688 from kacpherek/fix/mob-looping-sounds
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:25 +01:00
Ludovic Marie cdccd03c7d Merge pull request #2685 from kacpherek/fix/mob-state-sync-3
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:21 +01:00
Ludovic Marie 27943eb6c7 Merge pull request #2683 from kacpherek/fix/magma-lava-jump-cobweb
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:17 +01:00
Ludovic Marie 28fbc7ff47 Merge pull request #2666 from kacpherek/fix/fishing-hook-players
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:13 +01:00
Ludovic Marie 33ced209a0 Merge pull request #2663 from kacpherek/fix/remote-swing-hand-duration
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:08 +01:00
Ludovic Marie 8155baa6f3 Merge pull request #2403 from kacpherek/fix/cat-morning-gift
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said not to wait for his re-review), and this head (main merged in) is proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses, mob_ai). Thanks @kacpherek!
2026-10-27 19:06:53 +01:00
Ludovic Marie 423a87cf68 Merge pull request #2397 from kacpherek/fix/parrot-cookie
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said not to wait for his re-review), and this head (main merged in) is proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses, mob_ai). Thanks @kacpherek!
2026-10-27 19:06:18 +01:00
Ludovic Marie ff8a13c99a Merge remote-tracking branch 'origin/main' into refactor/block-entity-data 2026-10-27 19:03:59 +01:00
Ludovic Marie 0e1fcaa3cc Merge pull request #2310 from kacpherek/fix/2289-shield-pose
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:03:28 +01:00
Ludovic Marie 1b45824beb Merge pull request #2731 from kacpherek/feat/spear-lunge
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:01:42 +01:00
Ludovic Marie a5053a898c Merge pull request #2215 from ludovic111/refactor/horizon-items
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). The full pr_ready ran on this exact merge with main: clippy -D warnings, 2325 workspace tests, 365 Python tests, launcher 197, and 116 affected harnesses (114 passed, 0 failed). Verify is green here. rdbtCVS approved. Crate split, priority 1.
2026-10-27 19:01:21 +01:00
Ludovic Marie 980807b0f5 Merge pull request #2895 from ludovic111/feat/nightly-prune-builds
🤖 Agent: Merged by Ludovic's AI agent (orchestrator) after review: scheduled cleanup of nightly/build-* branches older than 7 days (never the newest or a published nightly's), as Ludovic decided.
2026-10-27 18:39:20 +01:00
Ludovic MarieandClaude Opus 5.5 610c2f3c10 launcher: Download and prepare, from Mojang, for an account that owns the game
With the owner gate in place, a signed-in owner no longer needs another
launcher first (brief mc-download-1, Ludovic 2026-10-27).

- The Play page's Resources row offers an owner "Download and prepare"
  and "From my install"; without an owner it asks to sign in first.
- `horizon-launcher prepare --download` does the same.
- Every file from Mojang must have the size and sha1 Mojang lists.
- The download shows its progress and has Cancel; files already whole
  are kept, so the next try goes on where it stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:33:53 +01:00
Ludovic MarieandClaude Opus 5.5 7afb41a128 launcher: the owner gate's screen, once for offline players and on a refused Play
A player without an account that owns Minecraft: Java Edition sees one
screen when the launcher opens (once) and whenever Play is refused: why
Horizon needs the account, like other launchers, that worlds, offline
names and settings stay where they are, and Sign in, which runs the
device-code sign-in on the same screen. Not now closes it; Home keeps
its Sign in row, which now opens this screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:25:30 +01:00
Ludovic MarieandClaude Opus 5.5 f6e96e484c Nightly: the scheduled run deletes nightly/build-* branches older than 7 days
Ludovic's decision (2026-10-27). `nightly.py prune-builds` runs in the
scheduled `pick` only, with main's code, and lists each deletion in the run
summary. It deletes only branches named nightly/build-<date>-<n> older than
7 days, never the newest build, and never one a published `nightly` or
nightly-<date> release points to. nightly-state and every other branch are
left alone. tests/test_nightly.py pins it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:23:53 +01:00
Ludovic MarieandClaude Opus 5.5 ce64c617e9 launcher: Play needs a Microsoft account that owns Minecraft: Java Edition
Like other launchers, the launcher now starts the game only while an
account in it owns Java Edition (Ludovic's decision, 2026-10-27).

- account::launch is the owner gate: it asks the Minecraft services
  again (entitlements and profile, after the token renewal), the
  selected account first, until one owns the game. Each answer is kept
  in the keychain item beside the tokens. No answer (no network) keeps
  the last one; "doesn't own" blocks at once.
- Every Play goes through it: the window (Home, instances, packs),
  `horizon-launcher play` and `modpack play`, the MCP's play.
- Offline names are added and played only beside an owner; signing the
  last owner out keeps them, unplayable.
- Home shows "Sign in with a Microsoft account that owns Minecraft:
  Java Edition to play" with Sign in; the "coming soon" sign-in strings
  are gone.
- The game binary, harnesses, CI and the servers are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:16:55 +01:00
socksproxandDevin 9b6faeb8cf launcher: the game's made-up name can be rolled for a new one
The game's own name (horizon-player.txt) isn't a kept offline name, so
Forget never applied to it and there was no way to be rid of it. Settings
> Account shows it with "New name", which writes a fresh PlayerNNNNN to
every existing game home — the default and each instance's — so the
player stays the same (new) player in its worlds, as PLAYER_FILES' copies
do.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:13:55 +01:00
Ludovic MarieandClaude Opus 5.5 1cfb4def89 Merge main into fix/mob-state-sync-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:12:13 +01:00
Ludovic MarieandClaude Opus 5.5 b2fd94f49f Merge main into fix/parrot-follow-sit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:55 +01:00
Ludovic MarieandClaude Opus 5.5 b8eac21b6f Merge main into fix/mob-looping-sounds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:22 +01:00
Ludovic MarieandClaude Opus 5.5 fad711f55f Merge main into feat/spear-lunge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:22 +01:00
Ludovic MarieandClaude Opus 5.5 4bc514829c Merge main into fix/magma-lava-jump-cobweb
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:21 +01:00
Ludovic MarieandClaude Opus 5.5 b8ae7233e0 Merge main into feat/spectator-menu
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:20 +01:00
Ludovic MarieandClaude Opus 5.5 5001e3f57d Merge main into fix/fishing-hook-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:20 +01:00
Ludovic MarieandClaude Opus 5.5 f731ca1737 Merge main into fix/bundle-dump-on-use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:19 +01:00
Ludovic MarieandClaude Opus 5.5 b124beafeb Merge main into fix/remote-swing-hand-duration
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:19 +01:00
Ludovic MarieandClaude Opus 5.5 c7f448c523 Merge main into fix/campfire-douse-client
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:18 +01:00
Ludovic MarieandClaude Opus 5.5 c7937c5c20 Merge main into fix/2289-shield-pose
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:18 +01:00
Ludovic MarieandClaude Opus 5.5 ed652da572 Merge main into fix/cat-morning-gift
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:17 +01:00
Ludovic MarieandClaude Opus 5.5 b28797c534 Merge main into fix/parrot-cookie
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:17 +01:00
Ludovic Marie 42ecf0f673 Merge pull request #2889 from ludovic111/chore/nightly-reviewer-online5277
🤖 Agent: Merged by Ludovic's AI agent (orchestrator): reviewer list fix (Online5277 instead of levzzz5154), as Ludovic asked.
2026-10-27 18:07:58 +01:00
socksproxandDevin (SWE-2) e36cd17dd1 Books: page click events work, run_command ones included
Two gaps left every link in a written book dead. view_pages only parsed
compound page components, so the list form ComponentSerialization also
takes (and servers behind ViaVersion send) fell back to plain text and
lost its click events. And run_command was never parsed into a
ClickEvent, so even a parsed page's command links did nothing.

The book's click handler now follows Screen.defaultHandleClickEvent:
open_url through the link flow, run_command sent unsigned (client mods
first, as clickCommandAction), suggest_command into the chat box,
copy_to_clipboard, with chat's own click match gaining run_command the
same way.

Co-Authored-By: Devin (SWE-2) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:06:53 +01:00
Ludovic Marie cf2e9d1bc0 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-27 18:03:10 +01:00
Ludovic Marie f8062e8fee Merge pull request #2583 from levzzz5154/fix/pot-back-face-culling
🤖 Agent: Merged by Ludovic's AI agent after the contribution gate (wave 5, cg20 union C, Mac mini), after #2296; main merged in since. Thanks @levzzz5154!
2026-10-27 18:02:10 +01:00
Ludovic Marie 7f760b8eb3 Merge pull request #2730 from kacpherek/fix/2714-windburst
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:58:46 +01:00
Ludovic Marie 8baf3cad9e Merge pull request #2727 from kacpherek/fix/2710-backwards-body-yaw
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:58:42 +01:00
Ludovic Marie 05f36b054c Merge pull request #2726 from kacpherek/fix/2709-dirtpath
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:58:38 +01:00
Ludovic Marie d89a8025f9 Merge pull request #2724 from kacpherek/fix/2717-flying-fall-distance
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:58:34 +01:00
Ludovic MarieandClaude Opus 5.5 674596103f nightly: Online5277 is the third trusted reviewer, not levzzz5154
Ludovic asked for his top three contributors by merged pull requests: kacpherek, rdbtCVS and Online5277.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:56:36 +01:00
Ludovic Marie 43d8bd01cd Merge pull request #2725 from kacpherek/fix/2702-toggle-sprint-ctrl-drop
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:55:00 +01:00
Ludovic Marie 18156172a3 Merge pull request #2728 from kacpherek/fix/2715-riptide
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:53:16 +01:00
masa-masa-0314andZCode e6c1a91a65 creative: rebuild the search state when the screen opens
CreativeModeInventoryScreen is a fresh screen per open (init() makes a new
search box and a zeroed scrollbar; only selectedTab is static), but Horizon
keeps one Creative for the game's lifetime, so the last search stayed in
the box after closing the inventory. Clear the box, refocus it on the
search tab and reset the scrollbar at both open paths (local and remote).

Fixes #2873.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 00:53:07 +08:00
Ludovic Marie 4c49c45a04 Merge pull request #2373 from kacpherek/fix/raid-recruit-needs-goal
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:51:15 +01:00
Ludovic Marie 7bd8747909 Merge pull request #2369 from kacpherek/fix/air-bubble-pop-sound
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:51:11 +01:00
Ludovic Marie 40e66ea972 Merge pull request #2365 from kacpherek/fix/give-overflow-drop
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:51:07 +01:00
Ludovic Marie 0acee0a978 Merge pull request #2686 from kacpherek/fix/wither-peaceful-raider-cap-trap-difficulty
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:51:02 +01:00
Ludovic Marie 324239ea84 Merge pull request #2662 from kacpherek/fix/village-siege
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:50:58 +01:00
socksproxandDevin 8cfc13fe2c launcher: the account list follows changes made outside the window
accounts.json was read once at start-up, so an offline name added or
forgotten through the CLI or another window left the running window
without the name's row (and its Forget button) until relaunch. Page
switches and opening the account chip's menu read it again, as the
game's made-up name already was.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:49:50 +01:00
Ludovic Marie 93c4acf169 Merge pull request #2661 from kacpherek/fix/chat-click-hover-events
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:44 +01:00
Ludovic Marie 2f8c781f04 Merge pull request #2658 from kacpherek/fix/death-message-styled
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:40 +01:00
Ludovic Marie 3cdd63e01d Merge pull request #2657 from kacpherek/fix/attack-speed-effects
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:35 +01:00
Ludovic Marie aace479a0c Merge pull request #2656 from kacpherek/fix/other-player-poses
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:31 +01:00
Ludovic Marie d54cab9565 Merge pull request #2653 from kacpherek/fix/2628-enderman-mouth
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:27 +01:00
Ludovic Marie a3c05538ca Merge pull request #2121 from kacpherek/fix/1938-ghast-dismount
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate: proven in cg21's union2 (union/cg21-kacpherek-2 077c10835: 80/82 harnesses on playhorizon, lan_e2e 9/9 in-frame and server-thread). CI parity failed only on remote_fireball/remote_fireworks, which fail on bare main on playhorizon's runner too and pass on the Mac mini.
2026-10-27 17:45:41 +01:00
Ludovic Marie fddd6b3864 Merge pull request #2649 from kacpherek/fix/sleep-status-overlay
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:44:09 +01:00
Ludovic Marie ee217acc19 Merge pull request #2407 from kacpherek/fix/spawn-protection
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:44:05 +01:00
Ludovic Marie 18510648ea Merge pull request #2406 from kacpherek/fix/flying-climbable
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:44:01 +01:00
Ludovic Marie f65a4e45cf Merge pull request #2389 from kacpherek/fix/weather-height-adjusted-temperature
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:43:57 +01:00
Ludovic Marie 28c105fa9b Merge pull request #2388 from kacpherek/fix/death-screen-quit-confirm
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:43:52 +01:00
Ludovic Marie 2cd77f6d49 Merge pull request #2684 from kacpherek/fix/turtle-goals
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:41:22 +01:00
Ludovic Marie a045782e04 Merge pull request #2677 from kacpherek/fix/mob-event-particles-to-clients
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:41:18 +01:00
Ludovic Marie c5f0414a0a Merge pull request #2676 from kacpherek/fix/golem-shears-uncertainty
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:41:14 +01:00
Ludovic Marie 2c82bbee6a Merge pull request #2673 from kacpherek/fix/server-thread-no-local-player
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:41:10 +01:00
socksproxandDevin a7e3bcf79f launcher: a forgotten offline name is gone for good
Forget removed the name from accounts.json but left skins/<name>.png and
skins/<name>.cape.png behind, the skin panel's file pick could still
record a look for a name that no longer existed, and the account rows'
Play as / Play offline / Sign out saved the window's stale account list,
which could write a name forgotten elsewhere (CLI, another window) back.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:41:07 +01:00
Ludovic Marie 0529ae8250 Merge pull request #2412 from kacpherek/fix/ambient-bush-eyeblossom-moss-effects
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:40 +01:00
Ludovic Marie 1c876168bd Merge pull request #2402 from kacpherek/fix/parrot-imitate-dance
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:23 +01:00
Ludovic Marie 48a3d8d175 Merge pull request #2394 from kacpherek/fix/parrot-shoulder
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:17 +01:00
Ludovic Marie f8040cb977 Merge pull request #2393 from kacpherek/fix/wolf-armor
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:13 +01:00
Ludovic Marie 1af3b2ebd9 Merge pull request #2375 from kacpherek/fix/warden-sonic-boom-knockback
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:09 +01:00
Ludovic Marie 1e8396db70 Merge pull request #2363 from kacpherek/fix/tp-facing-feet
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:05 +01:00
Ludovic Marie 6a031aa651 Merge pull request #2682 from kacpherek/feat/spear-use
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:36:52 +01:00
Ludovic Marie 98d53642fa Merge pull request #2387 from kacpherek/fix/recovery-compass-death-spot
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:56 +01:00
Ludovic Marie 60bcab5d61 Merge pull request #2380 from kacpherek/fix/comparator-reads-item-frame
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:50 +01:00
Ludovic Marie c083b9ed02 Merge pull request #2376 from kacpherek/fix/trial-mob-equipment
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:46 +01:00
Ludovic Marie 1ada2e33c8 Merge pull request #2348 from kacpherek/fix/silverfish-merge-and-wake-friends
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:42 +01:00
Ludovic Marie 2a6bf0fae5 Merge pull request #2342 from kacpherek/fix/skeleton-arrow-difficulty
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:37 +01:00
Ludovic Marie 5f14c125ed Merge pull request #2659 from kacpherek/fix/sweep-hits-players
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:33:29 +01:00
Ludovic Marie 152afb5529 Merge pull request #2357 from kacpherek/fix/shulker-bullet-levitation
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:34 +01:00
Ludovic Marie 3972efa333 Merge pull request #2338 from kacpherek/fix/enderman-angry-speed
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:30 +01:00
Ludovic Marie 4c348837e4 Merge pull request #2335 from kacpherek/fix/frost-walker
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:25 +01:00
Ludovic Marie 521a761745 Merge pull request #2328 from kacpherek/fix/arrows-bells-buttons
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:21 +01:00
Ludovic Marie bf11ca8ac9 Merge pull request #2324 from kacpherek/fix/plates-mobs-items
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:17 +01:00
Ludovic Marie df08ffda39 Merge pull request #2882 from ludovic111/fix/parity-host-excludes-locally
🤖 Agent: playhorizon's CI runner now skips the harnesses its host entry excludes (remote_fireball, remote_fireworks fail there on bare main from CPU starvation); pr_ready clean, unit test added.
2026-10-27 17:27:52 +01:00
Ludovic Marie 7d3aeb0315 Merge pull request #2399 from kacpherek/fix/wolf-spares-tamed-wolves
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:27:35 +01:00
Ludovic Marie d4b7ef198c Merge pull request #2359 from kacpherek/fix/piglins-hunt-adult-hoglins
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:26:04 +01:00
Ludovic Marie 6b011e436d Merge pull request #2356 from kacpherek/fix/trident-client-flight
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:26:00 +01:00
Ludovic Marie d98450a575 Merge pull request #2355 from kacpherek/fix/respawn-radius
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:25:56 +01:00
Ludovic Marie d860b9bfc0 Merge pull request #2343 from kacpherek/fix/endermite-lifetime
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:25:51 +01:00
Ludovic Marie 7f4ae40cc5 Merge pull request #2326 from kacpherek/fix/mob-cactus-berry-damage
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:25:47 +01:00
Ludovic Marie 7a53c8833b Merge pull request #2346 from kacpherek/fix/brush-suspicious-blocks
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:25:17 +01:00
Ludovic Marie f8e7517393 Merge pull request #2254 from kacpherek/fix/construct-beacon-trigger
🤖 Agent: Merged by Ludovic's AI agent (orchestrator): main merged in with the advancements resolution union2 proved; verify green. Thanks @kacpherek!
2026-10-27 17:24:47 +01:00
Ludovic Marie 42cbbb565e Merge pull request #2099 from kacpherek/fix/2087-pot-top
🤖 Agent: Read against the vanilla 26.3 sources (Decorated pot: draw one face of its top and bottom planes) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 17:17:48 +01:00
Ludovic Marie 4c7ada0d60 Merge pull request #2506 from masa-masa-0314/fix/animated-inventory-blocks
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Proven in union C (122/122 delta harnesses); its CI parity failed only on remote_fireball, a known load flake on playhorizon's runner that fails on bare main there too. Thanks @masa-masa-0314!
2026-10-27 17:11:38 +01:00
Ludovic MarieandClaude Opus 5.5 1788bf8134 Merge origin/main into fix/construct-beacon-trigger
advancements.rs keeps main's enchanted-item trigger (#2233) and the PR's
construct-beacon trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:11:08 +01:00
sirrayiandDevin 62bb83857c Update rustls-webpki to 0.103.16 and webpki-roots to 1.0.10 (RUSTSEC-2026-0382, -0381)
cargo deny check advisories flags rustls-webpki 0.103.15 (distrusted emSign intermediates may be accepted, RUSTSEC-2026-0382) and webpki-roots 1.0.9 (bundles the distrusted emSign ECC Root CA - G3, RUSTSEC-2026-0381). Both are patch releases; every HTTPS request of the game, the launcher, horizon-server and the telemetry server goes through them (ureq -> rustls). horizon-mod-registry's lock already has the fixed versions.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:10:51 +00:00
leifiyooandClaude Haiku 5.5 8d2b298253 Keep long Microsoft sign-in secrets in several keychain items
Credential Manager takes at most 2560 bytes per item. The secrets of a
Microsoft sign-in (the refresh token and the Minecraft token) are longer,
so saving them failed on Windows with "password encoded as UTF-16 is
longer than platform limit of 2560 chars" and the sign-in did not finish.

On macOS and Windows a secret is now split over the items `<uuid>`,
`<uuid>.1`, `<uuid>.2`... of at most 1000 UTF-8 bytes each (under 2560
bytes of UTF-16). Reads join the pieces, a write removes the leftovers
of a longer secret, and sign-out removes all of them. Existing single
items keep working.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-27 16:53:00 +01:00
Ludovic MarieandClaude Opus 5.5 9914a8af41 Name the wither's client smoke tick client_smoke
#2685 adds wither::client_tick for the side heads' turning on a client;
a distinct name lets both land without a clash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:47:06 +01:00
Ludovic Marie 9972feb710 Merge pull request #2235 from kacpherek/fix/instant-effects-rounding-splash-distance
🤖 Agent: Read against the vanilla 26.3 sources (Round instant potion effects and measure splash range from the body) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread; main merged in since, clippy clean. Thanks @kacpherek!
2026-10-27 16:44:03 +01:00
Ludovic MarieandClaude Opus 5.5 e776eb1716 Merge main into fix/magma-lava-jump-cobweb
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 78e635fe3e Merge main into fix/remote-swing-hand-duration
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 5e582420ed Merge main into fix/2714-windburst
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 3ff797d73d Merge main into fix/2710-backwards-body-yaw
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 1d52fe8101 Merge main into fix/2709-dirtpath
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 2487d29318 Merge main into fix/2717-flying-fall-distance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 3b8a8eeb91 Merge main into fix/2702-toggle-sprint-ctrl-drop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 063b6ee2b7 Merge main into fix/2715-riptide
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 4414c3c1af Merge main into fix/bundle-dump-on-use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:32 +01:00
Ludovic MarieandClaude Opus 5.5 6cf5b046fa Merge main into fix/2289-shield-pose
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:32 +01:00
Ludovic MarieandClaude Opus 5.5 d6ecd23a6e Merge main into fix/sleep-status-overlay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:32 +01:00
Ludovic Marie 12a8782268 Merge pull request #2864 from ludovic111/feat/nightly-launcher-text
🤖 Agent: verified (fmt, launcher lib tests 197 passed, fr/ru strings); #2859 merged, so the text is true now. Queued at 99ce9afd6.
2026-10-27 16:40:36 +01:00
Ludovic Marie 028f608816 Merge pull request #2859 from ludovic111/feat/nightly-reviewers-wire
🤖 Agent: Merged by Ludovic's orchestrator after review (nightly-2 wiring: /nightly for the trusted reviewers; nightly builds get their own cache key, never save it, and stay off the release runner).
2026-10-27 16:38:12 +01:00
Ludovic MarieandClaude Opus 5.5 0f8f8fce11 Merge main into fix/mob-state-sync-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:38:11 +01:00
Ludovic MarieandClaude Opus 5.5 18b317186a Merge main into fix/turtle-goals
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:54 +01:00
Ludovic MarieandClaude Opus 5.5 708db4627e Merge main into fix/fishing-hook-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:33 +01:00
Ludovic MarieandClaude Opus 5.5 e93299fb80 Merge main into fix/death-message-styled
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:25 +01:00
Ludovic MarieandClaude Opus 5.5 e12d4c9d07 Merge main into fix/ambient-bush-eyeblossom-moss-effects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:19 +01:00
Ludovic MarieandClaude Opus 5.5 505d17a425 Merge main into fix/parrot-shoulder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:07 +01:00
Ludovic MarieandClaude Opus 5.5 2d89e9ae5f Merge main into fix/recovery-compass-death-spot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:59 +01:00
Ludovic MarieandClaude Opus 5.5 14470343d1 Merge main into fix/air-bubble-pop-sound
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:44 +01:00
Ludovic MarieandClaude Opus 5.5 9d9389310d Merge main into feat/spectator-menu
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:43 +01:00
Ludovic MarieandClaude Opus 5.5 8649edaf16 Merge main into fix/village-siege
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:42 +01:00
Ludovic MarieandClaude Opus 5.5 3a8706d0d7 Merge main into fix/chat-click-hover-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:42 +01:00
Ludovic MarieandClaude Opus 5.5 0762f49f20 Merge main into fix/cat-morning-gift
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:41 +01:00
Ludovic MarieandClaude Opus 5.5 d980703786 Merge main into fix/other-player-poses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:41 +01:00
Ludovic MarieandClaude Opus 5.5 efd7b94c15 Merge main into fix/2628-enderman-mouth
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:41 +01:00
Ludovic MarieandClaude Opus 5.5 9f2e4485ad Merge main into fix/sweep-hits-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:41 +01:00
Ludovic MarieandClaude Opus 5.5 bbd5c1c833 Merge main into fix/parrot-follow-sit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:40 +01:00
Ludovic MarieandClaude Opus 5.5 639ac0e4c6 Merge main into fix/parrot-imitate-dance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:40 +01:00
Ludovic MarieandClaude Opus 5.5 76e3a0f7b7 Merge main into fix/wolf-armor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:40 +01:00
Ludovic MarieandClaude Opus 5.5 47e6541731 Merge main into fix/warden-sonic-boom-knockback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:39 +01:00
Ludovic MarieandClaude Opus 5.5 361020fc23 Merge main into fix/tp-facing-feet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:39 +01:00
Ludovic MarieandClaude Opus 5.5 417ee432c5 Merge main into fix/silverfish-merge-and-wake-friends
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:39 +01:00
Ludovic MarieandClaude Opus 5.5 accd2718ac Merge main into fix/skeleton-arrow-difficulty
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:38 +01:00
Ludovic MarieandClaude Opus 5.5 854bcb158f random_tick probe: a fresh suspicious block's reset check changes nothing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:35:08 +01:00
Ludovic MarieandClaude Opus 5.5 1bd74d1b2f Skip the world spawn trip's teleport when the respawn already put the player there
Vanilla teleports a respawning player once; the second, identical move
showed up in the dedicated_server, pearl_death and cursor_death harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 967a9363ce entity_death_loot: give wolves their gear in the body slot
Horizon now keeps a wolf's body armour in its head slot, as for the other
BODY types.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:35:07 +01:00
leifiyooandClaude Haiku 5.5 de286f236f Count down Riptide's spin on a server's copy of a player
The host runs a guest's trident release on its copy of the guest, which
sets the spin for 20 ticks. The copy never ran the client's movement
tick, so the count never went down: the copy stayed in the spin pose
(eye 0.4), and every arrow or projectile the guest shot was spawned from
the leg until something else reset it.

LivingEntity.aiStep counts autoSpinAttackTicks down on the server too.
Count it down in the guest's server tick, before updatePlayerPose.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-27 16:30:53 +01:00
Ludovic Marie ed10d376fa Merge pull request #2858 from ludovic111/feat/nightly-set
🤖 Agent: Merged by Ludovic's orchestrator after review (nightly-2: the /nightly set library; tools/ci/ is hand-merged).
2026-10-27 16:27:51 +01:00
Ludovic MarieandClaude Opus 5.5 8d5424408c Nightly: nothing an unreviewed build leaves reaches a release
Orchestrator's review of #2859: a nightly can hold pull requests nobody
reviewed yet, and their build scripts run in `build`. game-build.yml gets a
`trusted` input, true only from release.yml:

- the Rust cache (GitHub-hosted Linux and Windows): its own key for an
  untrusted build (`nightly-<name>`), never saved; a release restores only
  what a release saved;
- macOS: an untrusted build's exporters and build jobs run on the runner
  that already runs pull requests' parity jobs (macOS + horizon-game), never
  the release runner (MAC_RUNNER) nor parity-main's;
- a trusted build doesn't read the machine's shared sccache, which pull
  requests' jobs write (runner-env.sh: HORIZON_NO_SCCACHE).

tests/test_nightly.py pins all three.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:26:51 +01:00
Ludovic MarieandClaude Opus 5.5 99ce9afd63 Launcher: the Nightly setting says reviewers pick its pull requests
nightly-2: a Nightly can include pull requests not reviewed yet, picked by
trusted reviewers (docs/release-flow.md "Nightly channel"). The Settings row
says so, in English, French and Russian.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:20:32 +01:00
masa-masa-0314andZCode 91d401aa8a Subtitles: a server's client ticks the overlay too
Single player joins its integrated server as a client, so `Minecraft.tick`
takes the `tick_remote` path and never reaches `tick_mobs`, where the
subtitle overlay's tick had its only call. The overlay therefore never
started listening to the sound engine: no sound notified it, and the
closed captions stayed empty with "Show Subtitles" on or off.

Call `tick_subtitles` from `tick_remote_level` beside `tick_hud`, as the
local path does.

Co-Authored-By: ZCode (Z.ai) <[email protected]>
2026-10-27 23:17:06 +08:00
levzzzandminimax-m3.1-flash a7d0586d95 Merge upstream/main into fix/armadillo-roll-ball
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 20:16:53 +05:00
Ludovic MarieandClaude Opus 5.5 21ac444f28 refactor: block entity data moves down beside the chunk
Step A of the horizon-client crate split. `Chunk.block_entities` held
`containers::BlockEntity`, whose payloads were defined across containers,
brewing, signs, sculk, mobs::spawner, mobs::trial_spawner, vault, beehive
and command_block, so `chunk` reached most of the gameplay code.

The enum and every payload type now live in `chunk/block_entity.rs` as
plain data (definitions and derives). Every impl block stays in its
feature module, and each module re-exports its types under the old path,
so callers don't change. Also:

- `mobs::Who` moves to `horizon_client_core::who` (the vibration `Info`
  names it); mobs re-exports it.
- `skins::NameOrId` moves beside `SkullSkin`, which holds it; skins
  re-exports it.
- `Spawner.next`, the `Next` enum and `Selector.current` become
  `pub(crate)`, since their struct now lives in another module.
- The vault, spawner and trial_spawner harnesses record
  `chunk/block_entity.rs`.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:16:27 +01:00
Ludovic MarieandClaude Opus 5.5 7efd8ef33a Nightly tests: the scheduled refresh, the notes and the repo's reviewers list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:15:14 +01:00
Ludovic Marie c6633ea1bb Merge branch 'feat/nightly-set' into feat/nightly-reviewers-wire 2026-10-27 16:14:58 +01:00
Ludovic MarieandClaude Opus 5.5 6c0e262aa0 nightly_set tests: the repo's list file is checked with it (next PR)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:14:39 +01:00
Ludovic Marie 1e7e2ffb27 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-27 16:14:13 +01:00
Ludovic MarieandClaude Opus 5.5 c9990e43e9 nightly_set tests: the scheduled refresh and the notes move with nightly.py (next PR)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:14:01 +01:00
Ludovic MarieandClaude Opus 5.5 79eddec09c Nightly: trusted reviewers pick the pull requests with /nightly
Ludovic's decision (2026-10-27, from kacpherek's proposal under #2779): the
Nightly is where contributors test pull requests before the gate.
kacpherek, rdbtCVS and levzzz5154 (.github/nightly-reviewers.txt) and
Ludovic pick them with `/nightly 2640 2642`, `add`, `remove`, `clear`,
`status` or `dry-run` in any issue or pull request.

- nightly-command.yml (issue_comment, main's copy): checks who asked, saves
  the set on nightly-state, starts `nightly build`, answers "🤖 Nightly: …".
  It never checks out or merges a pull request.
- nightly-build.yml: `pick` builds main + the set (newest heads and merged or
  closed ones dropped at the scheduled run), pushes it as
  nightly/build-<date>-<n>, records it in builds.jsonl; build, sign and
  publish are unchanged; `answer` tells the thread how it ended.
- Notes say a Nightly can include pull requests not reviewed yet, picked by
  trusted reviewers, and list each with its author and who picked it.
- docs/release-flow.md: who can pick, how, how Ludovic removes or pauses them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:11:14 +01:00
Ludovic MarieandClaude Opus 5.5 0ff1df58a2 nightly_set.py: trusted reviewers' picks for the Nightly (library)
The pieces of nightly-2 (`/nightly 2640 2642`): parsing a comment, the
reviewers list (.github/nightly-reviewers.txt, with a `paused` line), the
refusals (paths CODEOWNERS gives to Ludovic, closed or merged PRs, more than
30, more than 6 builds a day), the set and its commands (Ludovic's remove
keeps reviewers from adding a PR back), the nightly-state branch (pushed with
retries when another run saved first), and the assembly: each PR head merged
onto main in order with `git merge-tree --write-tree`, objects only, so no
file, hook or filter of a pull request reaches the runner. Equal inputs give
the same commit. A conflict names the two pull requests (or main) and the
paths.

Nothing calls it yet: the next PR wires it into nightly-build.yml and a
`nightly command` workflow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:11:13 +01:00
Ludovic Marie 58beb98327 Merge pull request #2233 from kacpherek/fix/enchanted-item-trigger
🤖 Agent: Read against the vanilla 26.3 sources (Fire the enchanted_item advancement trigger at the enchanting table) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread; main merged in since, clippy clean. Thanks @kacpherek!
2026-10-27 16:08:02 +01:00
Ludovic Marie 96f84abfeb Merge pull request #2256 from kacpherek/fix/axe-frees-copper-golem-statue
🤖 Agent: Read against the vanilla 26.3 sources (An axe turns an unoxidized copper golem statue back into a golem) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread; main merged in since, clippy clean. Thanks @kacpherek!
2026-10-27 15:50:37 +01:00
Ludovic MarieandClaude Opus 5.5 0879f3dbd2 parity: a host's exclude list also holds on its own CI runner
playhorizon's runner fails remote_fireball and remote_fireworks even alone and
at bare main (3b19c386c: 2 of 2 runs and the retry), while the Mac mini passes
them; every fork PR whose parity lands there stopped on them. The host's
exclude list only kept harnesses off workers sent there, not off the runner's
own jobs: platform_skips now adds the local host's exclude, skipped with its
reason unless named. The two harnesses join playhorizon's list; the Mac
mini's runner and the nightly audit (macOS) keep running them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:37:04 +01:00
Ludovic Marie 27af1879d7 Merge pull request #2124 from kacpherek/fix/block-animate-gaps
🤖 Agent: Read against the vanilla 26.3 sources (Block ambient effects: levers, repeaters, dripstone, wither roses, End gateways, lightning rods) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 15:28:43 +01:00
Ludovic Marie 096814ade9 Merge pull request #2675 from kacpherek/fix/dolphin-goals
🤖 Agent: Merged main in (conflicts resolved, see the merge commit); proven in union R1b (branch union/cg19-r1b): clippy -D warnings, tests, the harnesses it can affect, lan_e2e in-frame and on the server thread.
2026-10-27 15:24:54 +01:00
Ludovic Marie d177ad48e5 Merge pull request #2360 from kacpherek/fix/dragon-egg-attack
🤖 Agent: Merged main in (conflicts resolved, see the merge commit); proven in union R1b (branch union/cg19-r1b): clippy -D warnings, tests, the harnesses it can affect, lan_e2e in-frame and on the server thread.
2026-10-27 15:21:13 +01:00
Alexander KrylovandHermes Agent c3fb1656e6 Fonts: Pixelify Sans gets the Cyrillic О and П it was missing
Upstream maps U+043E (о) and U+043F (п) and not their capitals, so a Russian
heading drew О and П from the fallback font: they came out smooth and taller
than the pixel letters around them (screenshots in the PR).

Both come from the font's own drawing. О is the Latin O's glyph under the
Cyrillic codepoint, which is the same round shape this font gives о: its
lowercase is the O with the top shifted down. П is that weight's lowercase п
with everything above the baseline lifted to the cap height the font uses there
(Н's top), 175 units in SemiBold, 177 in Medium and 171 in Bold, since each
weight draws its own metrics. Both now sit on the bbox of the O and Н of their
own weight.

О keeps the O's hinting instructions; П is a new outline with none, which a
pixel design does not need at the sizes the launcher draws it. GDEF, GPOS and
GSUB are byte-identical to the files they replace.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-27 17:19:10 +03:00
Ludovic Marie 21123aaa3c Merge pull request #2670 from kacpherek/fix/chat-options-page
🤖 Agent: Merged main in (conflicts resolved, see the merge commit); proven in union R1b (branch union/cg19-r1b): clippy -D warnings, tests, the harnesses it can affect, lan_e2e in-frame and on the server thread.
2026-10-27 15:17:32 +01:00
Ludovic Marie 65096c5b2e Merge pull request #2374 from kacpherek/fix/effect-icons-over-screens
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:17:28 +01:00
Ludovic Marie 9948e9d3ee Merge pull request #2372 from kacpherek/fix/nether-map-arrow-spin
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:17:24 +01:00
socksproxandDevin (SWE-2 High) b98633b677 Play the player's own bites while eating on a remote server
On a remote (non-Horizon) server the use-item tick spawned the crumb
particles but never played the consumable's eat sound, so only the burp
at the end was heard. Call emit_consume for the periodic emit, as
tick_used_item does on the player's own server, so the bites sound too.

Fixes #2747.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:14:34 +01:00
levzzzandminimax-m3.1-flash 3c7942d2bd Test that a hit rolls the armadillo up into a ball
An armadillo hit by a living thing curls up: Armadillo.actuallyHurt
rolls it up, Armadillo.rollUp plays entity.armadillo.roll and switches
the state to ROLLING, and ArmadilloState.shouldHideInShell then shows
the shell instead of the armadillo. The roll itself is on main (#2535),
but nothing held it in place, so this test pins it: a hit rolls the
armadillo up, the roll plays its sound and the model shows the shell
(#2759).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:14:31 +05:00
Ludovic Marie 3c3884ff38 Merge pull request #2245 from kacpherek/fix/crossbow-advancement-triggers
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate: proven in cg21's union2 (union/cg21-kacpherek-2 077c10835: 80/82 harnesses on playhorizon, lan_e2e 9/9 in-frame and server-thread). CI parity failed only on remote_fireball/remote_fireworks, which fail on bare main on playhorizon's runner too and pass on the Mac mini.
2026-10-27 15:14:14 +01:00
Ludovic Marie 30c58b6591 Merge pull request #2371 from kacpherek/fix/wolf-meat-heal
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:12:12 +01:00
Ludovic Marie fd2265dc19 Merge pull request #2370 from kacpherek/fix/johnny-vindicator-targets-raiders
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:12:08 +01:00
Ludovic Marie 84dcd156f8 Merge pull request #2368 from kacpherek/fix/raid-no-action-time
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:12:04 +01:00
Ludovic Marie 5ca6bd7de6 Merge pull request #2367 from kacpherek/fix/xp-clear-spawnpoint-targets
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:11:59 +01:00
Ludovic Marie 2a6b4c0ae6 Merge pull request #2366 from kacpherek/fix/clone-move-overlap
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:11:55 +01:00
Ludovic MarieandClaude Opus 5.5 d5aa3f6e42 Name the cat relax goal's owner check is_owner_of
The tame mob goals (#2382, #2399) add an is_owner(m, w) to this module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:11:34 +01:00
Ludovic MarieandClaude Opus 5.5 fbc7c628f6 Merge main into fix/give-overflow-drop
/give's overflow throw takes main's third argument (GiveCommand:
player.drop(copy, false)).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:10:11 +01:00
Ludovic Marie c20565ee49 Merge pull request #2364 from kacpherek/fix/dragon-strafe-line-of-sight
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:08:51 +01:00
Ludovic Marie 4e9c68e3da Merge pull request #2358 from kacpherek/fix/shulker-closed-armor
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:08:46 +01:00
Ludovic Marie 712144297a Merge pull request #2325 from kacpherek/fix/phantom-difficulty
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:08:41 +01:00
Ludovic Marie 651ac2cfd5 Merge pull request #2674 from kacpherek/fix/air-bubbles-eye-height
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:08:38 +01:00
Ludovic Marie 2853015f0e Merge pull request #2231 from kacpherek/fix/step-sound-above
🤖 Agent: Read against the vanilla 26.3 sources (Play the step sound of the block above the floor for the player) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 15:08:34 +01:00
Ludovic MarieandClaude Opus 5.5 0914229800 A parrot saves its owner in main's Variant arm
A second parrot arm was unreachable, so the variant was never saved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:07:40 +01:00
Ludovic MarieandClaude Opus 5.5 e350b87b3c End portal exits pass no spawn finder
main's item and mob End portal exit calls end_arrival, which now takes the
respawn search's finder; in the End it only builds the platform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:07:40 +01:00
Ludovic Marie 6567f95aa3 Merge pull request #2672 from kacpherek/fix/smithing-template-tooltip
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:51 +01:00
Ludovic Marie 1361ae8338 Merge pull request #2671 from kacpherek/fix/chat-minor
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:47 +01:00
Ludovic Marie d4412d160f Merge pull request #2655 from kacpherek/fix/book-sign-author
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:44 +01:00
Ludovic Marie 38ddc48cab Merge pull request #2654 from kacpherek/fix/host-sees-invisible-guests
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:40 +01:00
Ludovic Marie ef0d62e47e Merge pull request #2652 from kacpherek/fix/leave-bed-screen
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:35 +01:00
Ludovic Marie 6f4b1bf4c2 Merge pull request #2651 from kacpherek/fix/hotbar-pop-bundle-bar
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:59 +01:00
Ludovic Marie 691341b2b0 Merge pull request #2650 from kacpherek/fix/pvp-axe-disables-shield
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:55 +01:00
Ludovic Marie 83979b38d6 Merge pull request #2648 from kacpherek/fix/local-server-checks
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:51 +01:00
Ludovic Marie b189f1b44c Merge pull request #2647 from kacpherek/fix/creative-hotbar-save-load
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:48 +01:00
Ludovic Marie 958db5f40f Merge pull request #2646 from kacpherek/fix/2630-husk-hunger-shield
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:44 +01:00
Ludovic Marie 6b9d3309cc Merge pull request #2644 from kacpherek/fix/remote-break-item-change
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:00:02 +01:00
Ludovic Marie c5d737b536 Merge pull request #2643 from kacpherek/fix/2633-dragon-breath-colour
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:59:58 +01:00
Ludovic Marie 57fe750035 Merge pull request #2642 from kacpherek/fix/pick-item-server
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:59:54 +01:00
Ludovic Marie 3849e37393 Merge pull request #2640 from kacpherek/fix/hopper-entity-containers
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:59:48 +01:00
Ludovic Marie 9b091ed657 Merge pull request #2418 from kacpherek/fix/announce-player-achievements
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:59:43 +01:00
iLostName de89e758cc remote: a server's client doesn't load the loot tables on its first tick 2026-10-27 14:57:27 +01:00
Ludovic Marie 777e0e2eb5 Merge pull request #2416 from kacpherek/fix/riptide-lift-collision
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:37 +01:00
Ludovic Marie 04ecdee7a2 Merge pull request #2415 from kacpherek/fix/op-permission-level
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:33 +01:00
Ludovic Marie 6ddc3226d4 Merge pull request #2410 from kacpherek/fix/enforce-whitelist
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:29 +01:00
Ludovic Marie 1903865cd5 Merge pull request #2409 from kacpherek/fix/flying-edge-backoff
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:25 +01:00
Ludovic Marie d5d54a8775 Merge pull request #2405 from kacpherek/fix/lan-join-leave-host-chat
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:21 +01:00
Ludovic Marie c27e2d2c2d Merge pull request #2404 from kacpherek/fix/sulfur-cube-archetype-physics
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:58 +01:00
Ludovic Marie d463d793a9 Merge pull request #2398 from kacpherek/fix/lodestone-compass-use
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:54 +01:00
Ludovic Marie f10425675c Merge pull request #2395 from kacpherek/fix/horse-armor-protects
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:50 +01:00
Ludovic Marie aa8ab4253d Merge pull request #2392 from kacpherek/fix/loot-set-name
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:46 +01:00
Ludovic Marie fa56070097 Merge pull request #2390 from kacpherek/fix/block-loot-copy-components
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:41 +01:00
Ludovic Marie dd5863edc2 Merge pull request #2385 from kacpherek/fix/lightning-sky-flash
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:48:05 +01:00
Ludovic Marie 37cabdcb02 Merge pull request #2381 from kacpherek/fix/hud-text-above-bars
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:48:00 +01:00
Ludovic Marie a40ef4eee0 Merge pull request #2379 from kacpherek/fix/sculk-catalyst-nonplayer-kills
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:47:56 +01:00
iLostName 5f5a43fd54 focus loss doesn't open the pause menu over the pack prompt or a server dialog 2026-10-27 14:47:53 +01:00
Ludovic Marie 914a6956e9 Merge pull request #2378 from kacpherek/fix/sky-body-rain-brightness
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:47:51 +01:00
Ludovic Marie 698b49e3a5 Merge pull request #2362 from kacpherek/fix/shulker-peaceful
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:47:46 +01:00
sirrayiandDevin c0bf77f3c3 Telemetry server: count IPv6 clients by /64 in the rate limits
An IPv6 client rotating addresses inside its /64 got a fresh bucket for every address, so the per-address limits on batches, live pings, hub votes, reports and suggestions didn't hold, and the bucket maps grew until the daily upkeep. Key them by /64, as horizon-relay already does.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:45:27 +00:00
Ludovic Marie ec067f1e39 Merge pull request #2353 from kacpherek/fix/piglin-hurt-drops-admired-item
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:42:07 +01:00
Ludovic Marie 6081a66629 Merge pull request #2352 from kacpherek/fix/portal-pigmen-cooldown
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:42:03 +01:00
Ludovic Marie ee69413a68 Merge pull request #2351 from kacpherek/fix/ice-melts-when-mined
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:41:59 +01:00
Ludovic Marie fc4e7af854 Merge pull request #2344 from kacpherek/fix/creeper-fall-swell
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:41:54 +01:00
Ludovic Marie 0e069c31d7 Merge pull request #2243 from kacpherek/fix/bee-flower-effects
🤖 Agent: Read against the vanilla 26.3 sources (Bees get an effect from a wither rose or open eyeblossom instead of breeding) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread; main merged in since, clippy clean. Thanks @kacpherek!
2026-10-27 14:41:50 +01:00
Ludovic Marie fbbff39e07 Merge pull request #2341 from kacpherek/fix/fishing-rain-biome
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:36:42 +01:00
Ludovic Marie 49c2000de3 Merge pull request #2339 from kacpherek/fix/fishing-pickup-delay
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:36:37 +01:00
Ludovic Marie f561d4845b Merge pull request #2336 from kacpherek/fix/zombie-drowned-conversion-stops
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:36:31 +01:00
Ludovic Marie d6c4e5a027 Merge pull request #2333 from kacpherek/fix/farmer-requested-items
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:36:26 +01:00
Ludovic Marie ad271604af Merge pull request #2262 from kacpherek/fix/2209-flat-world-horizon
🤖 Agent: Read against the vanilla 26.3 sources (Flat worlds: no black horizon, the dark disc and void fog use the flat horizon) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 14:36:22 +01:00
iLostName 86474679db server packs: the prompt takes the open screen's place, as PackConfirmScreen does 2026-10-27 14:26:17 +01:00
Ludovic MarieandClaude Opus 5.5 c4a39757a1 Merge main into feat/spear-lunge
enchant_effects.rs: Lunge's post_piercing_attack effects beside main's
ApplyMobEffect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:21:38 +01:00
Ludovic MarieandClaude Opus 5.5 7690ad27c2 Merge main into fix/mob-looping-sounds
main's Drown event (entity event 67) stays beside this branch's entity
event doc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:19:47 +01:00
Ludovic MarieandClaude Opus 5.5 74da2fe6b7 Merge main into fix/mob-state-sync-3
- lan.rs shared flags: on fire, a drowned's swimming, invisible and main's
  glowing in one byte.
- The piglin's zombification immunity (16) goes into its existing arm (the
  shared arm could no longer reach it); the villager's unhappy counter
  beside main's mob_freeze_meta.
- remote_mob_data.rs: imports unioned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:17:14 +01:00
Ludovic MarieandClaude Opus 5.5 966c38092d Merge main into fix/turtle-goals
- turtle.rs: these goals and navigation beside main's TurtleMoveControl
  and its tests; the doc says what is still not ported.
- ai.rs: the turtle keeps its own register arm, so main's call from the
  generic arm goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:14:49 +01:00
Ludovic MarieandClaude Opus 5.5 6d4b5c345f Merge main into feat/spear-use
lan.rs: the spear's kinetic hits beside main's cushion break particles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:07:35 +01:00
Ludovic MarieandClaude Opus 5.5 8dcb4289a9 Merge main into fix/golem-shears-uncertainty
creatures.rs: shearing a snow golem or a bogged beside main's armadillo
brushing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 6df1dfd4da Merge main into fix/fishing-hook-players
Hook targets and pulls beside main's guest explosions and particles; imports
unioned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:05:55 +01:00
iLostName 560aa61e91 server packs: the download prompt keeps the pointer and stops the walking behind it 2026-10-27 14:05:45 +01:00
Ludovic MarieandClaude Opus 5.5 98ec18fcfe Merge main into fix/chat-click-hover-events
chat.rs: the run-command click beside main's page links and link prompt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:05:24 +01:00
Ludovic MarieandClaude Opus 5.5 683ff6f73a Merge main into fix/sweep-hits-players
The guests' sweeps (damage to the other players) beside main's guest
explosions and particles; both flush at the end of a guest's turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:04:31 +01:00
Ludovic MarieandClaude Opus 5.5 a791368257 Merge main into fix/2628-enderman-mouth
- lan.rs: the enderman's DATA_CREEPY after main's warden, sniffer and
  shulker data.
- remote_mob_data.rs: the creepy flag beside main's arms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:01:07 +01:00
Ludovic MarieandClaude Opus 5.5 af548f5ae8 Merge main into fix/cat-morning-gift
- PlayerView: sleeping and the sleep timer beside main's passenger flag.
- creatures.rs: DropGift's item motion uses nextDouble, as the ItemEntity
  constructor does; SetBlock takes main's setBlockAndUpdate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:59:04 +01:00
Ludovic MarieandClaude Opus 5.5 1e741d1528 Merge main into fix/parrot-imitate-dance
game.rs: jukebox songs for the guests beside main's cushion breaks and
mining cracks (level events 2019, 2020).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:57:19 +01:00
Ludovic MarieandClaude Opus 5.5 8fad68ab3e Merge main into fix/parrot-cookie
creatures.rs: the player's eye position beside main's acting UUID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:56:34 +01:00
Ludovic MarieandClaude Opus 5.5 32c8def114 Merge main into fix/parrot-shoulder
- creatures.rs: shoulder times beside main's guest particles, the parrot
  shoulder event beside main's Infested, parrot food beside main's acting UUID.
- entity_nbt.rs: a parrot saves its Owner and Sitting in main's Variant arm
  (a second arm was unreachable).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:55:55 +01:00
Ludovic MarieandClaude Opus 5.5 7c1055df97 Merge main into fix/wolf-armor
- Taming takes main's acting_player_uuid (the host gets a UUID made for
  it); acting_owner goes.
- Wolf armour is the body slot (6) on main (head_is_body): lan.rs sends
  its cracks and components there, and remote.rs reads them from slot 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:54:47 +01:00
Ludovic MarieandClaude Opus 5.5 d25648f729 Merge main into fix/comparator-reads-item-frame
game.rs GameLevel: the item frames beside main's cart signals and arrow
boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:52:12 +01:00
Ludovic MarieandClaude Opus 5.5 70dd250ae6 Merge main into fix/warden-sonic-boom-knockback
- creatures.rs: mob_hits_mob takes main's hit handling (infested
  silverfish, other events left in the outbox), so a sonic boom and a melee
  hit share it.
- mobs.rs: SonicBoom beside main's Shift.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:51:40 +01:00
Ludovic MarieandClaude Opus 5.5 c6276dbbde Merge main into fix/trident-client-flight
- trident.rs: main's thrower guest (player_hurt_entity) beside the owner id
  a server sends.
- remote.rs: the trident and main's firework rocket both take a server
  move at once; both take its velocity.
- remote_projectiles.rs: main's smoke trails stay; the trident flies by its
  own client tick instead of main's interpolation step.
- tests/test_remote_motion.py: both fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:50:45 +01:00
Ludovic MarieandClaude Opus 5.5 a0b4721c5b Merge main into fix/campfire-douse-client
remote.rs: main's command block editor opens on a reacting block; the
campfire douse prediction stays its own branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:49:34 +01:00
Ludovic Marie d136f966f8 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-27 13:49:06 +01:00
Ludovic MarieandClaude Opus 5.5 1f457ff81e Merge main into fix/silverfish-merge-and-wake-friends
spawner.rs: main's mob_poof (entity event 20 to the clients) and
poof_particles split.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:49:02 +01:00
Ludovic MarieandClaude Opus 5.5 9663d6f53e Merge main into fix/mob-cactus-berry-damage
mobs.rs: main's DryOut and piston deltas beside this branch's sweet berry
bush and stuck multiplier.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:48:12 +01:00
Ludovic MarieandClaude Opus 5.5 0d554de016 parity: game_loop records mouse, window and keymapping from the input crate
The harness hashed crates/horizon-client/src/{mouse,window,keymapping}.rs,
which this branch moves to horizon-client-input, so CI's parity job failed
with FileNotFoundError before running it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:48:09 +01:00
Ludovic MarieandClaude Opus 5.5 1bddc392b2 Merge main into fix/plates-mobs-items
- game.rs: the plate and tripwire mobs and other entities beside main's
  detector rail signal and arrow-pressed buttons.
- projectiles.rs: main's trident is 0.5 x 0.5 (EntityTypes.TRIDENT).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:47:37 +01:00
Ludovic Marie 4a8cb8c4f9 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/raider.rs
2026-10-27 13:45:00 +01:00
Ludovic Marie 6ef20dc9af Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/natural_spawning.rs
2026-10-27 13:44:58 +01:00
Ludovic Marie e219a40ebc Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/mobs.rs
2026-10-27 13:44:55 +01:00
Ludovic Marie 64ca4d485f Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 13:44:53 +01:00
Ludovic Marie 66b9b3323c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 13:44:49 +01:00
Ludovic Marie 24d06f5ce2 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/hand.rs
2026-10-27 13:44:48 +01:00
Ludovic Marie 19de683b66 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 13:44:47 +01:00
Ludovic Marie ed8418a2ac Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/animate_tick.rs
2026-10-27 13:44:44 +01:00
Ludovic Marie c33a2a5d19 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/lan/hub.rs
2026-10-27 13:44:43 +01:00
Ludovic Marie 9b49a0b727 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/motion.rs
2026-10-27 13:44:42 +01:00
Ludovic Marie 1b42652e96 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/tame.rs
2026-10-27 13:44:39 +01:00
Ludovic Marie 1400da9637 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/biome.rs
2026-10-27 13:44:35 +01:00
Ludovic Marie 62794a3fde Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/game/screens.rs
2026-10-27 13:44:34 +01:00
Ludovic Marie c17da16907 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/save.rs
2026-10-27 13:44:34 +01:00
Ludovic Marie 62c5a6d27b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/mobs/tame.rs
2026-10-27 13:44:32 +01:00
Ludovic Marie 9a9de6a936 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/finalize.rs
2026-10-27 13:44:31 +01:00
Ludovic Marie fe9b20733a Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-27 13:44:29 +01:00
Ludovic Marie 50f9255472 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands/execute.rs
2026-10-27 13:44:29 +01:00
Ludovic Marie 70181252d8 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 13:44:28 +01:00
Ludovic Marie dc3762b533 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/initial_spawn.rs
2026-10-27 13:44:26 +01:00
Ludovic Marie 4de85f000d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote.rs
2026-10-27 13:44:24 +01:00
Ludovic Marie 4de42d0dc4 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-10-27 13:44:22 +01:00
Ludovic Marie 747c2031f1 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-27 13:44:21 +01:00
Ludovic Marie 14353e67d4 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/enchant_effects.rs
2026-10-27 13:44:20 +01:00
Ludovic Marie 8027bda6bf Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-world/src/level/redstone.rs
2026-10-27 13:44:19 +01:00
Ludovic MarieandClaude Opus 5.5 0c5e30ae5e Merge main into fix/chat-options-page
main moved the Opt enum to settings.rs (#2781): the three chat options are
added there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:42:12 +01:00
Ludovic MarieandClaude Opus 5.5 35689a58ba Merge main into fix/dragon-egg-attack
interact.rs attack_block: the dragon egg teleports first, then main's note
block plays its note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:35:42 +01:00
Ludovic MarieandClaude Opus 5.5 79efeab0ce Merge main into fix/dolphin-goals
- goals.rs: main's player_in_range takes the visibility scaling; the
  through-walls check (forNonCombat) keeps it on.
- dolphin.rs: AroundSelf now names its mob and entity event (38).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:34:51 +01:00
Ludovic MarieandClaude Opus 5.5 be5d7ef703 Merge main into fix/animated-inventory-blocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:16:10 +01:00
Ludovic MarieandClaude Opus 5.5 ee49ec5071 Merge main into fix/pot-back-face-culling (item_render: ItemQuad keeps cull_back and main's translucent)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:15:03 +01:00
Ludovic MarieandClaude Opus 5.5 e6b92995f3 Merge origin/main into fix/instant-effects-rounding-splash-distance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:10:01 +01:00
Ludovic MarieandClaude Opus 5.5 3094896ad1 mob_ai probe: count hits that bring an effect as player hurts
The new AttackPlayerWithEffect hit (husk, bee, cave spider...) still hurts
the oracle's player, so the probe prints its hurt sound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:02:29 +01:00
Ludovic Marie 9f0c201851 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-27 12:51:52 +01:00
Ludovic MarieandClaude Opus 5.5 6d55c17322 Merge origin/main into fix/crossbow-advancement-triggers
advancements.rs keeps main's levitation and using-item triggers and the PR's
killed-by-arrow trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:51:00 +01:00
Ludovic MarieandClaude Opus 5.5 9305cc3439 Merge origin/main into fix/2087-pot-top
entity.wgsl: main's glint_uv takes location 11, so the PR's cull flag moves
to location 12.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:49:15 +01:00
Ludovic MarieandClaude Opus 5.5 8187f1ef66 Merge origin/main into fix/construct-beacon-trigger
advancements.rs keeps main's new triggers and the PR's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:33:29 +01:00
Ludovic MarieandClaude Opus 5.5 05f3355c57 Merge origin/main into fix/enchanted-item-trigger
advancements.rs keeps main's new triggers and the PR's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:33:27 +01:00
Ludovic MarieandClaude Opus 5.5 f90cdb04a5 Merge origin/main into fix/1938-ghast-dismount
Keeps main's mob_meta call (with the game time) and the PR's precise flag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:24:08 +01:00
Ludovic MarieandClaude Opus 5.5 78d4d7ede4 Merge origin/main into fix/crossbow-advancement-triggers
Resolved as in contrib-gate-21's union: both triggers and the damage
predicate kept; the arrow-kill trigger fires after PLAYER_HURT_ENTITY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:23:08 +01:00
Ludovic MarieandClaude Opus 5.5 fff7116b51 Format the chat options or-pattern
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:12:33 +01:00
Ludovic MarieandClaude Opus 5.5 ca351542ef Merge origin/main into fix/block-animate-gaps
animate_tick.rs keeps main's poplar leaves ambient sound and the PR's
dripstone drip, both whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:11:25 +01:00
Ludovic MarieandClaude Opus 5.5 d811847b2d Merge main into fix/chat-options-page
main added Chat Links Prompt as a setting of its own (settings.chat_links_prompt,
read by the server links screen); the option keeps that one instead of a
second, file-backed copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:03:25 +01:00
Ludovic MarieandClaude Opus 5.5 0c58226e9c Merge main into fix/2630-husk-hunger-shield
The bee's poison keeps main's difficulty rule (none on easy) and comes
only with a sting that hurt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:59:21 +01:00
Ludovic MarieandClaude Opus 5.5 745a3ef110 Merge main into fix/dolphin-goals
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:40 +01:00
Ludovic MarieandClaude Opus 5.5 eace14a013 rustfmt the step-sound match arms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:19 +01:00
Ludovic MarieandClaude Opus 5.5 5adf5c43a2 Merge main into fix/hotbar-pop-bundle-bar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:12 +01:00
Ludovic MarieandClaude Opus 5.5 0075879d79 Merge origin/main into fix/axe-frees-copper-golem-statue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:01 +01:00
Ludovic MarieandClaude Opus 5.5 1727f95575 Merge origin/main into fix/construct-beacon-trigger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:01 +01:00
Ludovic MarieandClaude Opus 5.5 73a9345c43 Merge origin/main into fix/bee-flower-effects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:00 +01:00
Ludovic MarieandClaude Opus 5.5 0cecbef5c3 Merge origin/main into fix/instant-effects-rounding-splash-distance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:00 +01:00
Ludovic MarieandClaude Opus 5.5 4dec228d0c Merge origin/main into fix/enchanted-item-trigger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:56:59 +01:00
Ludovic MarieandClaude Opus 5.5 9b4b2e2db3 Merge origin/main into fix/axe-frees-copper-golem-statue
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:52 +01:00
Ludovic MarieandClaude Opus 5.5 d511139408 Merge origin/main into fix/bee-flower-effects
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:49 +01:00
Ludovic MarieandClaude Opus 5.5 3ec521749b Merge origin/main into fix/instant-effects-rounding-splash-distance
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:32 +01:00
Ludovic MarieandClaude Opus 5.5 3093f8c594 Merge origin/main into fix/2209-flat-world-horizon (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:28 +01:00
Ludovic MarieandClaude Opus 5.5 831efabfbd Merge origin/main into fix/step-sound-above (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:27 +01:00
Ludovic MarieandClaude Opus 5.5 cae7cc02d3 Merge origin/main into fix/block-animate-gaps (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:26 +01:00
Ludovic MarieandClaude Opus 5.5 7d54dc4eaa Merge origin/main into fix/1938-ghast-dismount (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:25 +01:00
Ludovic MarieandClaude Opus 5.5 9b98998991 Merge origin/main into fix/2087-pot-top (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:24 +01:00
Ludovic Marie 1db0eda4ae Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 11:33:45 +01:00
Ludovic MarieandClaude Opus 5.5 d4137afb8c Merge origin/main into fix/step-sound-above
Step sounds: main's emission arms (no crouch guard) read the PR's muffled
flag; MotionResources keeps both the step-sound tags and main's crystal tag.
The amethyst chime (Entity.walkingStepSound) now also follows an inside or
combination step, as vanilla plays it after playStepSound whatever block
that sounded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:29:32 +01:00
Ludovic MarieandClaude Opus 5.5 905fd0d193 Merge origin/main into fix/2209-flat-world-horizon
Remote keeps main's use_item field and the PR's public dimension field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:26:21 +01:00
Ludovic MarieandClaude Opus 5.5 ef54b481a7 Merge origin/main into fix/2087-pot-top
Item quads keep both flags: the PR's cull_back (entitySolid back-face culling)
and main's translucent (the translucent item sheet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:23:36 +01:00
Ludovic MarieandClaude Opus 5.5 3a5dd71152 Merge origin/main into fix/1938-ghast-dismount
Keeps both the happy ghast's precise-position flag and main's freeze metadata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:19:03 +01:00
Ludovic MarieandClaude Opus 5.5 2c783d099a Merge origin/main into fix/block-animate-gaps
Keeps both the PR's lever, repeater, dripstone, wither rose, end gateway and
lightning rod animations and main's wet sponge and suspicious sand/gravel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:18:07 +01:00
Kacpherek e314c2fa81 Implement the spear's left-click jab and the Lunge enchantment 2026-10-27 11:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 0f565f4e4c refactor: biome's spawn-list parsers live in biome.rs
parse_entries, parse_costs and SpawnCost read the biome JSON's
natural_mob_spawns and build biome::SpawnEntry; biome.rs reached into
mobs::spawn_selection for them, one of the edges that keep the client lib
in a single cycle. They move to biome.rs and spawn_selection re-exports
them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:11:39 +01:00
Kacpherek 3c85d2558b Tell a server player's client about its velocity after a mace smash 2026-10-27 11:11:36 +01:00
Ludovic Marie d8cc59dd55 Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-27 11:10:57 +01:00
Kacpherek a05dc35c39 Launch a Riptide trident on a server's client when the use is released 2026-10-27 11:08:49 +01:00
Kacpherek 5d68512994 Keep the body facing forward when a player walks backwards 2026-10-27 11:08:07 +01:00
Kacpherek 377fecbf79 Draw a quad over fully opaque texels without the alpha cutout
A dirt path's side faces skip the clear top row of their texture, but the
cutout still ran on them: mip filtering blended that row into the top edge
and dropped it. Vanilla bakes each quad's layer from the texels under its
UVs, so these quads are solid.
2026-10-27 11:07:53 +01:00
Kacpherek a23a7ec35a Drop the whole stack on Ctrl+Q by the physical Control key only 2026-10-27 11:06:35 +01:00
Kacpherek 50cd5576f9 Clear a flying player's fall distance on the server tick 2026-10-27 11:06:03 +01:00
Ludovic MarieandClaude Opus 5.5 8648470c47 rustfmt the merged item_data.rs line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:06:54 +01:00
Ludovic MarieandClaude Opus 5.5 eb664d50c1 pr_ready: blocks.rs and items.rs live in horizon-items now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:04:38 +01:00
Ludovic Marie e42ec2360c Merge remote-tracking branch 'origin/main' into refactor/horizon-items
# Conflicts:
#	crates/horizon-items/src/item_data.rs
2026-10-27 09:04:26 +01:00
Ludovic MarieandClaude Opus 5.5 beac63849c refactor: move the client's input modules into horizon-client-input
Key bindings and names, toggle keys, mouse options, controller settings,
the clipboard and window options move to their own crate, re-exported under
their old paths. Their unit tests now rebuild in about a second after an
edit instead of rebuilding the client lib's test binary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:42:36 +01:00
Ludovic MarieandClaude Opus 5.5 716cc7f102 Merge main into fix/crossbow-advancement-triggers
Keeps main's piercing count with this branch's fired_from_weapon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:55:21 +01:00
Ludovic MarieandClaude Opus 5.5 c35436a7da Merge main into fix/instant-effects-rounding-splash-distance
Splashes carry both the potion's age (for the distance) and main's
thrower (for the indirectMagic source).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:15 +01:00
Kacpherek fc06585938 Send mob particles to clients the way vanilla does 2026-10-27 06:45:32 +01:00
Ludovic Marie f8582fb678 Merge #2296 (enchantment glint)
# Conflicts:
#	crates/horizon-client/src/entity.wgsl
2026-10-27 06:40:21 +01:00
Ludovic Marie 9777b525b7 Merge #2296 (enchantment glint)
# Conflicts:
#	crates/horizon-client/src/hud.rs
2026-10-27 06:40:20 +01:00
Kacpherek 97d119b4e8 Loop the bee buzz and the guardian attack hum like vanilla 2026-10-27 06:39:31 +01:00
Kacpherek bca4513187 Keep withers out of Peaceful, raiders out of the spawn count, and use local difficulty for skeleton traps 2026-10-27 06:37:08 +01:00
Kacpherek 28b72d46c5 Give turtles vanilla's goals: panic, tempt, go to water, travel and stroll 2026-10-27 06:36:15 +01:00
Kacpherek cece5aca30 Send more mob state to the single player window
A client of a server only draws what the server sends. Send and read the
synched data for sleeping villagers (pose and bed), horses grazing and
rearing, invisible mobs, a carried enderman block, swimming drowned, zombie
villager cures, the villager head shake, zombie drowning conversion,
hoglin and piglin conversion, charged blazes, turtle eggs, baby zoglins and
the wither's side head targets.

A wither's side heads also need a line of sight to shoot, as in vanilla.
2026-10-27 06:36:13 +01:00
Kacpherek 713de31f64 Slow mobs in cobwebs and make magma cubes jump out of lava 2026-10-27 06:36:04 +01:00
Kacpherek e2a48d1c36 Implement the spear's use action: charge, stab, knockback, dismount 2026-10-27 06:36:00 +01:00
Kacpherek 9142f57bc8 Add the spectator menu with Teleport to Player and Teleport to Team Member 2026-10-27 06:33:52 +01:00
Kacpherek 306c6625e9 Shear snow golems and bogged by hand and use vanilla's snowball spread 2026-10-27 06:33:07 +01:00
Kacpherek 46f674f842 Give dolphins swim-with-player, jump and treasure goals and fish feeding 2026-10-27 06:32:31 +01:00
Kacpherek dca3bb89fe Show the air bubbles only when the eyes are below the water surface 2026-10-27 06:32:01 +01:00
Kacpherek e74a1fe461 Use every player in the level for patrols, sniffers, allays, trap horses and other world checks on the server thread 2026-10-27 06:31:46 +01:00
Kacpherek 581828bf94 Show the smithing template lines in item tooltips 2026-10-27 06:30:47 +01:00
Kacpherek 2c0d3c1a86 Chat wheel counts right Shift, durability line is translated 2026-10-27 06:30:46 +01:00
Kacpherek 62556429dc Add the four missing options to the Chat page 2026-10-27 06:30:09 +01:00
Kacpherek eeceb9c2de Let a fishing hook catch and pull other players 2026-10-27 06:27:40 +01:00
Kacpherek 2a1132387f Swing the right arm and for the right time on other players 2026-10-27 06:26:53 +01:00
Kacpherek ca83704cbb Add zombie village sieges 2026-10-27 06:26:12 +01:00
Kacpherek 180e2f612a Chat lines: server message bar and tooltip, item hover, run_command clicks, Shift insertion 2026-10-27 06:26:12 +01:00
Kacpherek 7b84a7ae91 Let a sweep attack hurt and push back the other players next to the target 2026-10-27 06:25:43 +01:00
Kacpherek ffad9e1f0a Keep the styling of the death message on the death screen 2026-10-27 06:25:21 +01:00
Kacpherek 1131a2910d Apply Haste and Mining Fatigue to the attack speed 2026-10-27 06:24:43 +01:00
Kacpherek 18217532af Show other players gliding, swimming, spinning and holding a loaded crossbow 2026-10-27 06:24:15 +01:00
Kacpherek 6d69bba15e Remote mining test fixture: the held-item key and the attack strength reset 2026-10-27 06:24:05 +01:00
Kacpherek 71b2f110eb Show the account name on the book signing screen 2026-10-27 06:23:59 +01:00
Kacpherek b6c77e12f3 Hide invisible guests in the host window 2026-10-27 06:23:43 +01:00
Kacpherek 5bbb61aec2 Angry endermen open their mouth on a server thread 2026-10-27 06:23:23 +01:00
Kacpherek 1f98f3ca89 Open the Leave Bed chat screen while the player sleeps 2026-10-27 06:23:22 +01:00
Kacpherek 0cd3410a2d Squeeze hotbar slots that gain items and draw the bundle fullness bar 2026-10-27 06:23:01 +01:00
Kacpherek 4afb5748f2 Let a player's axe hit disable the victim's shield 2026-10-27 06:22:13 +01:00
Kacpherek 6b1d376e6f Show the sleeping players count on the action bar of a LAN host or server 2026-10-27 06:22:11 +01:00
Kacpherek 008c4f91cd Treat single player as a local server for the tab list and F3 charts 2026-10-27 06:21:37 +01:00
Kacpherek 85f34393fe Save and load creative toolbars with the toolbar keys 2026-10-27 06:21:01 +01:00
Kacpherek 59bdb00599 Husk, cave spider, wither skeleton, bee and pufferfish effects only come when the hit lands 2026-10-27 06:19:41 +01:00
Kacpherek 29ab66935e Restart a break when the held item changes and reset the attack strength when it stops 2026-10-27 06:17:48 +01:00
Kacpherek 80fd68e1c2 Send the dragon's breath cloud particle to the client 2026-10-27 06:16:45 +01:00
Kacpherek f92cf816b1 Pick blocks and entities on the server like vanilla 2026-10-27 06:16:32 +01:00
Kacpherek 0c9bddecda Let block hoppers move items into and out of minecart containers 2026-10-27 06:11:01 +01:00
levzzz 0ee20da35c Merge remote-tracking branch 'upstream/main' into fix/pot-back-face-culling 2026-10-27 09:49:34 +05:00
levzzzandminimax-m3.1-flash b7749dd0d2 Fix a needless borrow in the decorated pot's test
Clippy's needless_borrow: the registry is already a reference, so the
extra & is dereferenced right away.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 09:38:03 +05:00
masa-masa-0314andZCode 18b544770f Inventory: frame strips by the mcmeta's frame size
`decode_one` cropped an animated block or item strip as `h > w -> w x w`,
ignoring the mcmeta's `animation.width`/`height`: a pack texture that
names its frame dimensions, or lays its frames along a row, packed the
whole image as "frame 0" and was then drawn as a square frame over a
rect sized for the image. The first frame now comes from
`AnimationMetadataSection.frameSize`, resampled to its longer side the
way the world's sprite arrays keep a frame square, and the sprite's GUI
size is that square too.

The atlas also gains the regression test the review asked for: a
synthetic two-frame block strip whose mcmeta starts on the second frame
registers in `sprite_animations`, and the atlas holds that frame at the
sprite's rect; a row strip with explicit frame dimensions packs only its
left frame.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 12:17:54 +08:00
Ludovic MarieandClaude Opus 5.5 03cc30ae45 Merge origin/main into refactor/horizon-items
entity_nbt keeps the name_text re-export (main's body is the same as
horizon-client-core's) and takes main's new spawn_bonus. Main's edits to
blocks, bundle, enchant, item_data and items land in horizon-items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:28:06 +01:00
Ludovic MarieandClaude Opus 5.5 80e28dfe63 creative_mining harness: items.rs now lives in horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:49:17 +01:00
Ludovic MarieandClaude Opus 5.5 27f73e1db3 Merge origin/main into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:49:17 +01:00
Ludovic MarieandClaude Opus 5.5 75bcbce441 Merge origin/main into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:27:09 +01:00
Ludovic MarieandClaude Opus 5.5 2f489f54c5 Merge origin/main (with #2160) into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:38:11 +01:00
levzzzandminimax-m3.1-flash 7250944a7c Test the decorated pot item's back-face culling too
The pot in the hand and dropped on the ground is drawn by the same
`DecoratedPotRenderer` model, through the item path, so its quads carry
the cull as well. The test fails without it.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:06:20 +05:00
levzzzandminimax-m3.1-flash ac33fb74ec Add the decorated pot's back-face culling regression test
`DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
whose pipeline culls back faces. The pot's `top` and `bottom` are flat
boxes, so without that cull both sides of each plane are drawn and they
z-fight: the pot renders wrong (kacpherek's #2099 draws the cull).

The test renders a plain pot and checks that all of vanilla's 28 submitted
quads are kept and that every one of them is marked for the cull, so the
behaviour cannot silently regress. It fails without the cull.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:57:27 +05:00
levzzz 5ac1c82eb9 Merge kacpherek's PR #2099: the decorated pot culls its back faces
The decorated pot's model parts are submitted with RenderTypes.entitySolid,
whose pipeline culls back faces; the pot's top and bottom planes are flat
boxes, so without the cull both sides of each plane are drawn and they
z-fight. This keeps all of vanilla's 28 submitted quads and culls the back
faces, like entitySolid does.
2026-10-27 04:36:29 +05:00
Ludovic MarieandClaude Opus 5.5 5f73d0d02d Merge refactor/horizon-command (with main) into refactor/horizon-items
Main's items.rs changes (equip_damage_on_hurt, resists_cactus) land in
crates/horizon-items/src/items.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:20:35 +01:00
masa-masa-0314andZCode 024415e09d Inventory: animate every animated block and item sprite
The shared atlas packs animated block and item textures as their first
frame, and only fire re-drew itself: an animated block held or previewed
in the GUI (sculk, magma) sat still, while the same block animated in
the world. The atlas now keeps every animated block or item sprite's
frame list and re-uploads the current frame into its rect as the
animation ticks, with the mcmeta's interpolation mixing the frame into
the next one the way the world's sprite arrays already do, so GUI items
animate like vanilla's stitched atlas.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 05:11:35 +08:00
Kacpherek e9797d69d0 Use the berry damage type for sweet berry bushes and check the whole move for contact 2026-10-26 21:51:25 +01:00
Kacpherek 4b4361fbc0 Clear a piglin's hoglin target once it dies or disappears 2026-10-26 21:48:20 +01:00
Kacpherek b0ce58adea Check the cat's owner and sync lying and relaxing to clients 2026-10-26 21:48:18 +01:00
Kacpherek 6145077d7e Make a tame parrot answer only to its owner 2026-10-26 21:44:11 +01:00
Kacpherek 512e003cbd Wolf armor: check the acting player's UUID and keep the armor's components 2026-10-26 21:43:50 +01:00
Kacpherek 2a297a43eb Credit the feeder with a parrot's cookie kill 2026-10-26 21:42:51 +01:00
Kacpherek 7eaa5f592b Send jukebox play and stop events to guests so their parrots dance 2026-10-26 21:38:21 +01:00
Kacpherek be714b38bb Only let a tame parrot land on its saved owner 2026-10-26 21:38:16 +01:00
Kacpherek 1a5e5eb358 Owner combat goals only follow the wolf's own owner 2026-10-26 21:37:58 +01:00
Kacpherek fba004eb3d Skip leaves in the no-leaves heightmap for firefly bush sounds 2026-10-26 21:22:41 +01:00
Kacpherek 2d70e55fbb Load the respawn search's candidate chunks instead of skipping unloaded ones 2026-10-26 21:21:42 +01:00
Kacpherek d0df238972 Include equipment modifiers in a mob's knockback resistance for the sonic boom push 2026-10-26 21:19:49 +01:00
Kacpherek d97b58fd4a Send the thrower's entity id with a tracked trident 2026-10-26 21:15:49 +01:00
Kacpherek e872d3ae54 Send isFlat in every dimension and use the dimension's min y for the horizon 2026-10-26 20:26:12 +01:00
Kacpherek fbdd04d817 Use armor stand, boat, cart and item eye heights for facing entity eyes 2026-10-26 19:58:03 +01:00
Kacpherek aed4e26f59 Apply the legacy announce-player-achievements key
The key was parsed and dropped. It now sets the show_advancement_messages game rule when the dedicated server starts, like DedicatedServer.initServer.
2026-10-26 19:54:23 +01:00
Kacpherek b5adad15eb Stop the riptide ground lift at ceilings 2026-10-26 19:53:42 +01:00
Kacpherek d17df3ca05 Give /op the level from op-permission-level
The console's /op always granted level 4. It now grants the permission level set in server.properties, like PlayerList.op.
2026-10-26 19:53:41 +01:00
Kacpherek 2d72e987c5 Play ambient sounds and spawn fireflies around firefly bushes, eyeblossoms, pale moss and dry plants 2026-10-26 19:53:15 +01:00
Kacpherek 9ae3882c1a Kick unlisted players when enforce-whitelist is on
Whitelist remove, reload and on, and deop, now disconnect online players who are not on the whitelist, like MinecraftServer.kickUnlistedPlayers.
2026-10-26 19:52:46 +01:00
Kacpherek 89c195fa6e Don't back off from edges while flying 2026-10-26 19:51:46 +01:00
Kacpherek de808e3612 Empty a bundle by holding right click 2026-10-26 19:51:31 +01:00
Kacpherek 4575f4f1df Honour spawn-protection on a dedicated server
Players who are not operators can no longer start digging or use an item on a block within the radius of the world spawn. They get the spawn protection message and the client gets the block back, like DedicatedServer.isUnderSpawnProtection.
2026-10-26 19:51:18 +01:00
Kacpherek 54b1c4f941 Make climbing follow vanilla: no climbing while flying, open trapdoors over ladders climb 2026-10-26 19:51:03 +01:00
Kacpherek 8a309c393d Show join and leave messages in a LAN host's own chat 2026-10-26 19:50:50 +01:00
Kacpherek 58a99a2ce9 Sulfur cubes bounce, slide and keep speed by their block's archetype 2026-10-26 19:49:41 +01:00
Kacpherek 23019a7bf9 Cats lie beside a sleeping owner and may leave a morning gift 2026-10-26 19:48:21 +01:00
Kacpherek 78a5ee0e55 Let parrots imitate nearby mobs and dance to a jukebox
Parrots only ever made their own sound. Follow Parrot.aiStep: one tick in
400 a parrot imitates a random mob within 20 blocks (half the time), and
its ambient sound is sometimes a mob's voice. A parrot within 3.46 blocks
of a playing jukebox now uses the party pose (level events 1010 and 1011).
2026-10-26 19:43:53 +01:00
Kacpherek c30d51d65e Wolves fight other owners' tamed wolves 2026-10-26 19:41:45 +01:00
Kacpherek c439b806b8 Let a compass lock onto a lodestone
Using a compass on a lodestone now sets its lodestone target (in place for a single compass in survival, otherwise one is split off), and the target clears when the lodestone is gone. The target also travels in item packets on the server thread.
2026-10-26 19:40:59 +01:00
Kacpherek 50fdcad10f A cookie poisons and kills a parrot 2026-10-26 19:40:42 +01:00
Kacpherek 2886b8f9ba Horse armor reduces the damage a horse takes 2026-10-26 19:39:31 +01:00
Kacpherek 9ca7162169 Let a tame parrot sit on its owner's shoulder
A tame parrot never landed on its owner. Add LandOnOwnersShoulderGoal:
when the parrot touches its owner, it is stored in the player's
ShoulderEntityLeft or ShoulderEntityRight and removed from the world.
It comes back, still tame, when the player falls, swims, flies, sleeps,
is hurt, dies, turns spectator or spins with a riptide.

The shoulder data is the player's own save data, so it also works for
players on the integrated server and for LAN guests.
2026-10-26 19:39:14 +01:00
Kacpherek de2df51193 Wolf armor: put it on, repair it, shear it off, and let it take the damage 2026-10-26 19:38:43 +01:00
Kacpherek dc36089156 Apply the set_name loot function to generated items 2026-10-26 19:37:51 +01:00
Kacpherek 08c959807d Broken heads keep their skin and silk-touched hives keep their bees and honey 2026-10-26 19:37:01 +01:00
Kacpherek 796dc320d9 Use the height adjusted biome temperature for the rain and snow line on the client 2026-10-26 19:36:45 +01:00
Kacpherek f1949c3bda Ask before the death screen's Title Screen button leaves the world 2026-10-26 19:36:39 +01:00
Kacpherek 4d9ead721e Point the recovery compass at the player's last death
The local player's last death location is now recorded, saved as LastDeathLocation, taken from the Login and Respawn packets on the server thread, and given to the item owner.
2026-10-26 19:36:35 +01:00
Kacpherek b8d0590d6d Lightning flash: mix the sky colour toward the flash tint instead of replacing it 2026-10-26 19:34:47 +01:00
Kacpherek bc2dd35cc3 Raise the held item name and action bar above extra heart, armor and mount rows 2026-10-26 19:33:57 +01:00
Kacpherek b5a735c8d1 Let tame parrots follow their owner and sit when told to
A tamed parrot ignored its owner: it never followed, and right-clicking
it did nothing. Give the parrot the goals of Parrot.registerGoals
(SitWhenOrderedToGoal, FollowOwnerGoal 1.0, 5.0, 1.0), let the owner
toggle sitting with a click while it stands on the ground, and keep the
owner and sitting state in the save file and in the data a guest gets.
2026-10-26 19:33:51 +01:00
Kacpherek 8c0ed0e591 Let a comparator read an item frame through a block
A comparator behind a solid block now reads the item frame on its far side (rotation + 1, or 0 when empty), and updates when the frame's item or rotation changes.
2026-10-26 19:33:24 +01:00
Kacpherek 849b5b663b Let a sculk catalyst take the experience of mobs killed without a player 2026-10-26 19:33:09 +01:00
Kacpherek b65b0a6a7d Dim the sun and moon in rain and turn the moon texture the right way up 2026-10-26 19:32:59 +01:00
Kacpherek f1181d4a25 Trial chamber mobs spawn with the gear their spawner's equipment table gives 2026-10-26 19:32:16 +01:00
Kacpherek c5a37c02de Push sonic boom targets only when hit, by their knockback resistance 2026-10-26 19:32:01 +01:00
Kacpherek 74dc389321 Keep the effect icons on screen over the pause menu, chat and other screens 2026-10-26 19:31:55 +01:00
Kacpherek 7d7a85d184 Recruit nearby raiders only while the raid pathfinding goal runs
Raiders recruited other raiders every 20 ticks whether or not they were
heading for the raid. In vanilla the recruiting is part of
PathfindToRaidGoal.tick, so a raider that has a target or carries a
rider does not recruit. Do the recruiting from the goal's tick.
2026-10-26 19:31:28 +01:00
Kacpherek de1e5f7292 Spin the player arrow on a Nether map
Vanilla's MapItemSavedData.calculateRotation gives the arrow a changing rotation in the Nether. The player marker used the yaw there too, so it never spun.
2026-10-26 19:31:10 +01:00
Kacpherek 9bc05faf53 Tamed wolves heal twice the food's nutrition 2026-10-26 19:30:54 +01:00
Kacpherek 575cef1d26 Let a vindicator named Johnny attack other raiders too
Vanilla's Johnny goal targets any living entity that can be attacked.
Horizon skipped pillagers, vindicators, witches, evokers, illusioners
and ravagers, so Johnny left them alone. Remove that filter.
2026-10-26 19:30:38 +01:00
Kacpherek 0cc3f567fc Play the bubble pop sound when an air bubble bursts 2026-10-26 19:30:29 +01:00
Kacpherek cb97c5496e Raiders in a raid no longer count idle time twice as fast
A raider's idle counter grew by 4 per tick while in a raid, so it
dropped out of the raid after about 700 ticks instead of 1400 and could
no longer be recruited sooner than in vanilla. Remove the extra tick
added by the raid bookkeeping; the counter now grows by 3 like vanilla.
2026-10-26 19:30:07 +01:00
Kacpherek 8ca15ae2ca Apply /xp, /clear and /spawnpoint to each target, not only the host 2026-10-26 19:23:47 +01:00
Kacpherek 10872bdd64 Add the tridents field to the remote motion test fixture 2026-10-26 19:22:53 +01:00
Ludovic MarieandClaude Opus 5.5 fc8e505c95 Merge refactor/horizon-command (with main and #2316) into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:22:52 +01:00
Kacpherek 3b24aceb04 Let /clone move work on overlapping regions 2026-10-26 19:21:53 +01:00
Kacpherek a4ccf205f7 Throw what /give cannot fit from the player's eyes, for that player only 2026-10-26 19:21:09 +01:00
Kacpherek 452fca8bdd Merge origin/main into fix/1938-ghast-dismount 2026-10-26 19:21:07 +01:00
Kacpherek 9dbd0a4baf Make the strafing dragon charge its fireball only with a clear line of sight 2026-10-26 19:19:36 +01:00
Kacpherek 9aabeb39ee Make /tp facing look from the feet, or the eyes under anchored eyes 2026-10-26 19:18:50 +01:00
Kacpherek 605e8a1488 Stop shulkers from targeting and shooting in Peaceful 2026-10-26 19:18:08 +01:00
Kacpherek dfd0601163 Teleport a dragon egg when it is left-clicked 2026-10-26 19:16:52 +01:00
Kacpherek 034af45777 Make adult piglins hunt adult hoglins 2026-10-26 19:15:33 +01:00
Kacpherek 0b3e40f9d4 Give a closed shulker its 20 covered armor 2026-10-26 19:15:28 +01:00
Kacpherek a44581eaf3 Let a shulker bullet's levitation run out on mobs 2026-10-26 19:14:23 +01:00
Kacpherek 886a84dea0 Fly thrown tridents on by themselves between server updates 2026-10-26 19:14:04 +01:00
Kacpherek 79c3896449 Respawn on a free column within the respawn radius around the world spawn 2026-10-26 19:13:58 +01:00
Kacpherek 6823e34451 Let a piglin let go of the gold it admires when a player hits it 2026-10-26 19:12:31 +01:00
Kacpherek f25fbb6f2e Give zombified piglins that spawn in a nether portal a portal cooldown 2026-10-26 19:11:24 +01:00
Kacpherek 872409ea57 Mined ice melts into water unless the tool has silk touch 2026-10-26 19:08:19 +01:00
Kacpherek 81b4c09ee6 Count the endermite lifetime with NoAI set 2026-10-26 19:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 a07c6d57dc horizon-items: item_name reads name_text from horizon-client-core
Main's item_name (d983ef89b) called crate::entity_nbt::name_text, which
the client re-exports from horizon_client_core::component.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:04:00 +01:00
Ludovic MarieandClaude Opus 5.5 3c94b5441e Merge refactor/horizon-command (with main) into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:59:46 +01:00
Kacpherek 5b89508fe9 Predict putting out a lit campfire with a shovel on the client
In single player and LAN the client never recognized the shovel on a lit
campfire, so there was no smoke and a shield in the other hand was raised
too. The client now shows the smoke, puts the fire out locally and ends
the click after the hand that doused.
2026-10-26 18:58:36 +01:00
Kacpherek b92dd80566 Let silverfish hide in stone and wake their friends when hurt 2026-10-26 18:55:02 +01:00
Kacpherek 4084bcdf75 Let a brush dust suspicious sand and gravel 2026-10-26 18:54:11 +01:00
Kacpherek 5657080884 Swell a creeper's fuse when it falls 2026-10-26 18:51:45 +01:00
Kacpherek 708702917c Remove an endermite after 2400 ticks 2026-10-26 18:51:16 +01:00
Kacpherek 4c8ff02969 Make skeleton arrow spread and damage follow the difficulty 2026-10-26 18:51:03 +01:00
Kacpherek fe167781c4 Give the fishing rain bonus only where rain falls 2026-10-26 18:50:36 +01:00
Kacpherek 0d02a4c722 Let a caught fish be picked up right away 2026-10-26 18:50:04 +01:00
Kacpherek 9d4c984e03 Speed up an enderman while it has a target 2026-10-26 18:49:31 +01:00
Kacpherek e4cd56ed7b Make Frost Walker freeze water and protect from magma 2026-10-26 18:49:17 +01:00
Kacpherek f2424043f3 Stop zombie drowning conversion when it leaves the water 2026-10-26 18:49:12 +01:00
Kacpherek 45996d8d78 Match the farmer's requested items to vanilla
The farmer's list had food, torchflower seeds and pitcher pods and lacked bone meal. Vanilla requests wheat, wheat seeds, beetroot seeds and bone meal. Food is already picked up and traded through its villager food value.
2026-10-26 18:48:10 +01:00
Kacpherek 472da6509d Projectiles ring bells they hit 2026-10-26 18:45:57 +01:00
Kacpherek b283f2ad31 Hurt mobs that touch a cactus or a sweet berry bush
Mobs ignored both blocks. A mob inside a cactus now takes 1 damage a tick,
and a mob inside a sweet berry bush is slowed (0.8, 0.75, 0.8) and, in a
grown bush, takes 1 damage when it moved at least 0.003 on X or Z. Foxes
and bees are left alone.
2026-10-26 18:45:44 +01:00
Kacpherek 6dcf4b3e8b Use the real local difficulty for the phantom spawn check
The phantom spawn gate assumed a fixed effective difficulty of 2. Use the local difficulty at the player instead, so easy, hard, a long-played chunk and the moon phase change how often phantoms spawn.
2026-10-26 18:45:43 +01:00
Kacpherek bf508c2842 Let mobs, dropped items and projectiles trigger pressure plates and tripwires 2026-10-26 18:45:39 +01:00
Kacpherek d18197a176 Shield: hold it up in first person and show the block pose on other players 2026-10-26 18:14:24 +01:00
Kacpherek c987778e97 Decorated pot: keep every quad and cull back faces like entitySolid 2026-10-26 16:34:24 +01:00
Kacpherek 9841ce7824 Revert "Decorated pot: draw one face of its top and bottom planes"
This reverts commit 408a75aeb3.
2026-10-26 16:27:13 +01:00
Kacpherek 83a6f5c7b9 Revert "Decorated pot: say why only the outer faces stay"
This reverts commit d338bcd070.
2026-10-26 16:27:13 +01:00
Ludovic MarieandClaude Opus 5.5 775e5b19cb horizon-items: tests load prepared items without the client's Assets
#2038's bundle tooltip tests and #2037's loot tests share one helper that
reads the block registry and facts directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:59:37 +01:00
Ludovic Marie f56bf1ba38 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-26 15:58:59 +01:00
Ludovic MarieandClaude Opus 5.5 ca119bb069 Merge origin/main into refactor/horizon-items
#2037's loot-enchantment tests stay in horizon-items: they load the
prepared items through the block registry and facts directly instead of
the client's Assets::load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:57:41 +01:00
Kacpherek 3c013dac75 An axe turns an unoxidized copper golem statue back into a golem
The golem stands where the statue was, facing the same way, the axe
loses durability and the statue block is removed.
2026-10-26 15:18:03 +01:00
Kacpherek e87e839c97 Fire the construct_beacon trigger when a beacon activates 2026-10-26 15:17:44 +01:00
Kacpherek 94592a35ac Use the flat world's horizon height for the dark disc and void fog 2026-10-26 15:16:33 +01:00
Kacpherek 581dc08cf5 Fire shot_crossbow and killed_by_arrow advancement triggers 2026-10-26 15:15:43 +01:00
Kacpherek 1b4fe0f067 Wither rose and open eyeblossom give bees an effect instead of breeding them 2026-10-26 15:15:28 +01:00
Kacpherek d2105fe66c Round instant potion effects and measure splash range from the body
Instant health and harm round to the nearest point instead of truncating. A splash potion measures the distance to the entity's box, not its centre, so a potion landing next to a player hits harder and reaches farther.
2026-10-26 15:14:30 +01:00
Kacpherek 20630b1230 Fire the enchanted_item trigger when an enchanting table enchants 2026-10-26 15:14:25 +01:00
Kacpherek 66637220ef Play the step sound of the block above the floor for the player
Powder snow, lily pads, carpets, snow layers and similar blocks now sound like vanilla when walked on: inside blocks replace the floor's step, combination blocks play their own step with the floor's step muffled.
2026-10-26 15:13:58 +01:00
Ludovic MarieandClaude Opus 5.5 27d750f1c9 Split horizon-items out of horizon-client
Second slice of the crate split, a pure move like the first: blocks (+ blocks/), items, item_data, enchant,
cooldowns, bundle and random_sequences move to horizon-items. They are the first layer mobs/ needs, after
horizon-client-core.

Items moved out of lib modules that sit in the big cycle, unchanged:
- firework shapes, explosions and dye colours (particles.rs) -> firework.rs;
- BannerLayer and its NBT form (banner.rs) -> banner_layer.rs;
- the ItemStack NBT codec (save.rs) -> item_nbt.rs;
- consumable_animation (first_person.rs) -> use_animation.rs;
- the content namespaces (mod_content.rs) -> content_namespaces.rs;
- save.rs's NBT value helpers -> horizon-client-core's nbt_value.rs, and name_text (entity_nbt.rs) -> core's
  component.rs.

horizon-client re-exports every module and item under its old path. Test helpers other client tests use
(Items::for_tests, enchant::tests) are behind horizon-items' `test-support` feature, enabled by
horizon-client's dev-dependency. The items test that loads the client's assets moved to the client:
always_edible_and_consume_effects_loaded, to assets.rs. particles.rs' test module moved to the end of the file
(clippy's items_after_test_module had been quiet only thanks to the derives that moved out).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:46:48 +01:00
Ludovic MarieandClaude Opus 5.5 d338bcd070 Decorated pot: say why only the outer faces stay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:16:31 +01:00
Kacpherek 5d72fdd8c2 Add ambient effects for levers, repeaters, dripstone, wither roses, End gateways and lightning rods
These blocks have an animateTick in vanilla but nothing happened around them in Horizon. Each gets the same particles and odds as vanilla.
2026-10-26 13:02:23 +01:00
Kacpherek 946f47b3ec Keep a happy ghast solid and still under a rider who gets off it
On the server thread the window never learned the ghast was on its still
timeout, and its copy trailed the server's, so a rider dropped through the
ghast and the ghast flew off. Sync STAYS_STILL, assume the timeout on
dismount, and put the window's ghast under the landing spot.
2026-10-26 12:48:36 +01:00
Kacpherek 408a75aeb3 Decorated pot: draw one face of its top and bottom planes 2026-10-26 11:45:03 +01:00
397 changed files with 18831 additions and 3139 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
+21 -4
View File
@@ -19,6 +19,15 @@ on:
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
type: string
default: ''
trusted:
# Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose
# build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an
# untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway),
# and a trusted one never reads the machine's shared sccache, which pull requests' jobs write
# (tests/test_nightly.py pins all of it).
description: The source is main (a release); false for anything else (a nightly)
type: boolean
default: false
developer_id:
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
type: boolean
@@ -27,8 +36,10 @@ permissions:
contents: read
jobs:
exporters:
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
@@ -39,6 +50,8 @@ jobs:
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
@@ -90,7 +103,7 @@ jobs:
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
@@ -117,7 +130,9 @@ jobs:
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
@@ -173,6 +188,8 @@ jobs:
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
+97 -7
View File
@@ -1,13 +1,23 @@
name: nightly build
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night it builds the newest
# union branch on origin (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or
# main when no union is newer, and publishes it as the `nightly` pre-release. Launchers install it only for players
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
# picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin
# (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or main when no union is
# newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly
# on. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the union's code:
# Why the signing key never meets the pull requests' code:
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
# so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
# - `build` (game-build.yml) checks out and compiles the union with no secrets: its token reads contents only and
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
# never saved; and releases don't read the machine's shared sccache.
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
# which stays locked for nightlies).
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
@@ -15,6 +25,9 @@ name: nightly build
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
@@ -31,22 +44,42 @@ on:
description: Build and sign with a throwaway key, publish nothing
type: boolean
default: false
issue:
description: The issue or pull request whose `/nightly` comment asked for this build (answered there)
type: string
default: ''
request:
description: The command's id, in the run's name so it can link the run
type: string
default: ''
prs:
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
type: string
default: ''
permissions:
contents: read
concurrency:
# One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick
# is lost.
group: nightly-build
cancel-in-progress: false
jobs:
pick:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
issues: write # says in the thread when the picked pull requests conflict
pull-requests: write
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
short: ${{ steps.pick.outputs.short }}
source: ${{ steps.pick.outputs.source }}
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -60,9 +93,32 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
NIGHTLY_ISSUE: ${{ inputs.issue }}
NIGHTLY_PRS: ${{ inputs.prs }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
- name: Old nightly/build-* branches deleted (scheduled runs only)
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- name: The source of the build (horizon-source.tar.gz)
if: steps.pick.outputs.build == 'true'
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
@@ -78,6 +134,7 @@ jobs:
version: ${{ needs.pick.outputs.version }}
nightly: ${{ needs.pick.outputs.nightly }}
developer_id: false
trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner
sign:
needs: [pick, build]
@@ -103,7 +160,7 @@ jobs:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -146,6 +203,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
@@ -157,10 +215,42 @@ jobs:
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
run: |
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
ls -l dist
answer:
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
needs: [pick, build, sign, publish]
if: always() && inputs.issue != '' && needs.pick.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
ISSUE: ${{ inputs.issue }}
BUILD: ${{ needs.pick.outputs.build }}
PUBLISHED: ${{ needs.publish.result }}
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }}
RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [[ "$BUILD" != true ]]; then
text="nothing new: the published Nightly is already this build."
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
text="the dry run built and signed $NAME; nothing was published. $RUN"
elif [[ "$PUBLISHED" == success ]]; then
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
else
text="the build failed, nothing was published: $RUN"
fi
gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"
+46
View File
@@ -0,0 +1,46 @@
name: nightly command
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever; a pull request's own copy of it
# is never read.
# - It checks out main and runs main's nightly_set.py. The comment's text and author reach it as env vars, never in
# a shell line.
# - It never checks out, fetches or merges a pull request: it reads their file lists through the API. The merge
# happens in nightly-build.yml's `pick`, with git objects only; their code runs in its `build`, without secrets.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: startsWith(github.event.comment.body, '/nightly') && github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # the nightly-state branch (main is protected)
actions: write # starts `nightly build`
issues: write # answers in the thread
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/CODEOWNERS
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/nightly_set.py command
+1
View File
@@ -90,6 +90,7 @@ jobs:
with:
ref: ${{ needs.decide.outputs.head }}
version: ${{ needs.decide.outputs.version }}
trusted: true # main: the release runner, the release cache, no shared sccache
sign:
needs: [decide, build]
+48
View File
@@ -0,0 +1,48 @@
name: triage command
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
# tools/ci/triage_command.py).
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever.
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: >-
github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/close') ||
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
tools/ci/triage_command.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/triage_command.py command
+7 -5
View File
@@ -35,9 +35,11 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `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-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `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-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `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` |
@@ -57,7 +59,7 @@ 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),
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/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
@@ -113,14 +115,14 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| 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` |
| 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`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.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: 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` |
@@ -129,7 +131,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| 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`, `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` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.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`; `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)) |
+4 -1
View File
@@ -86,6 +86,9 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
@@ -98,5 +101,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
Generated
+30 -6
View File
@@ -1370,7 +1370,6 @@ name = "horizon-client"
version = "0.1.0"
dependencies = [
"ab_glyph",
"arboard",
"bytemuck",
"chrono",
"cpal",
@@ -1380,11 +1379,13 @@ dependencies = [
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1421,6 +1422,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-client-input"
version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
@@ -1469,6 +1482,17 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-items"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"serde",
"serde_json",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
@@ -3182,9 +3206,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4362,14 +4386,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
+2
View File
@@ -8,7 +8,9 @@ members = [
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Ludovic
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+7 -7
View File
@@ -3,7 +3,7 @@
</p>
<p align="center">
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
@@ -15,7 +15,7 @@
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
@@ -29,9 +29,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
@@ -99,7 +99,7 @@ Horizon contains no game files and never downloads them. It builds its resources
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
```sh
git clone https://github.com/ludovic111/horizon.git
git clone https://github.com/horizon-rust/horizon.git
cd horizon
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
@@ -158,7 +158,7 @@ them too (Rust uses Java's own float maths on each platform).
### Known issues
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Licence
+66 -66
View File
@@ -32,7 +32,7 @@ Making Horizon faster than vanilla on every computer, with smooth camera movemen
<details><summary>Technical</summary>
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/horizon-rust/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/horizon-rust/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/horizon-rust/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
</details>
@@ -42,7 +42,7 @@ Speeding up the code that reads and writes the blocks of the world, which almost
<details><summary>Technical</summary>
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
</details>
@@ -54,7 +54,7 @@ Making the world-generation mods of popular modpacks (new structures, YUNG's dun
<details><summary>Technical</summary>
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/horizon-rust/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/horizon-rust/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/horizon-rust/horizon/issues/1753), [#1757](https://github.com/horizon-rust/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -66,7 +66,7 @@ When you play on a server, other players, mobs, items and menus look and behave
<details><summary>Technical</summary>
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
</details>
@@ -76,7 +76,7 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
<details><summary>Technical</summary>
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
26.3's dialog packets and model ([#1720](https://github.com/horizon-rust/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/horizon-rust/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/horizon-rust/horizon/issues/1735)).
</details>
@@ -86,7 +86,7 @@ A /pregen command that builds an area of the world ahead of time, so players don
<details><summary>Technical</summary>
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/horizon-rust/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
</details>
@@ -98,7 +98,7 @@ One place for mods, modpacks and resource packs made for Horizon: browse them on
<details><summary>Technical</summary>
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/horizon-rust/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/horizon-rust/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/horizon-rust/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/horizon-rust/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/horizon-rust/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/horizon-rust/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/horizon-rust/horizon/issues/1637)), the Mods page ([#1638](https://github.com/horizon-rust/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/horizon-rust/horizon/issues/1640)).
More: [docs/mods.md](docs/mods.md)
@@ -110,7 +110,7 @@ Mods that run on the server side of a world, so a dedicated server or a LAN worl
<details><summary>Technical</summary>
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
</details>
@@ -120,7 +120,7 @@ Mods will be able to open their own screens and settings pages and react to keys
<details><summary>Technical</summary>
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
More: [docs/mods.md](docs/mods.md)
@@ -132,7 +132,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
<details><summary>Technical</summary>
`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.
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/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: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
@@ -146,7 +146,7 @@ Single player now runs a small server in the background, as vanilla does. We are
<details><summary>Technical</summary>
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -156,7 +156,7 @@ Item names, container titles and server messages follow the language you pick, a
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/horizon-rust/horizon/issues/1774)).
</details>
@@ -166,9 +166,9 @@ After you throw an ender pearl, its slot should show the cooldown sweeping away,
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/horizon-rust/horizon/issues/1763).
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
</details>
@@ -178,9 +178,9 @@ The Hidden and Commands Only chat settings should hide chat, and team prefixes s
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/horizon-rust/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/horizon-rust/horizon/issues/1378)). Both issues have exact steps to reproduce.
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
</details>
@@ -190,9 +190,9 @@ The mouse pointer should turn into a pointing hand over menu buttons, as in vani
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/horizon-rust/horizon/issues/1347).
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
</details>
@@ -202,9 +202,9 @@ Ctrl+Backspace should delete a whole word in the creative search, and every list
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/horizon-rust/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/horizon-rust/horizon/issues/1782), after [#978](https://github.com/horizon-rust/horizon/issues/978)).
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
</details>
@@ -214,9 +214,9 @@ Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/horizon-rust/horizon/issues/1706).
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
</details>
@@ -228,9 +228,9 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
<details><summary>Technical</summary>
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
</details>
@@ -240,7 +240,7 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
<details><summary>Technical</summary>
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/horizon-rust/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/horizon-rust/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/horizon-rust/horizon/issues/1861)), the sky stage ([#1863](https://github.com/horizon-rust/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/horizon-rust/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
</details>
@@ -252,7 +252,7 @@ Your Discord profile can show that you are playing Horizon, with a switch to tur
<details><summary>Technical</summary>
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
A Discord IPC client on its own thread ([#1787](https://github.com/horizon-rust/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/horizon-rust/horizon/issues/1788)). Merged: it ships with the next release.
</details>
@@ -264,7 +264,7 @@ Cleaning up Horizon's code: removing what nothing uses and making it quicker to
<details><summary>Technical</summary>
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
</details>
@@ -278,7 +278,7 @@ New public numbers comparing Horizon with vanilla on many kinds of computers, me
<details><summary>Technical</summary>
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
Redo the comparison after the current performance work ([#1073](https://github.com/horizon-rust/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/horizon-rust/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/horizon-rust/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
</details>
@@ -288,7 +288,7 @@ Creating a world and joining it should be quick even on a four-thread laptop, wh
<details><summary>Technical</summary>
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/horizon-rust/horizon/issues/1760) and [#1778](https://github.com/horizon-rust/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
</details>
@@ -320,7 +320,7 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
<details><summary>Technical</summary>
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
</details>
@@ -330,7 +330,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
@@ -364,7 +364,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -386,7 +386,7 @@ Set up Horizon, a server first, without Java on your computer: published tables
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
</details>
@@ -406,7 +406,7 @@ Smoother hosting: a real Windows service, a backup before every update, backups
<details><summary>Technical</summary>
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/horizon-rust/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
</details>
@@ -464,7 +464,7 @@ Mods that add machines: blocks that tick, hold items and have their own menus. T
<details><summary>Technical</summary>
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
</details>
@@ -486,7 +486,7 @@ Pistons and slime blocks will push players, mobs and items as in vanilla. Today
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
</details>
@@ -506,7 +506,7 @@ Villages and fights behaving as in vanilla: iron golems defending villages, vill
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
</details>
@@ -516,7 +516,7 @@ Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
</details>
@@ -544,7 +544,7 @@ Coming soon: sign in with your Microsoft account to play on online-mode servers.
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
</details>
@@ -566,7 +566,7 @@ Builds of the game and the launcher for Macs with an Intel processor.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
</details>
@@ -576,7 +576,7 @@ Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboar
<details><summary>Technical</summary>
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
</details>
@@ -626,7 +626,7 @@ Safer releases: changes to the release machinery need the owner's review, and AI
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
</details>
@@ -636,7 +636,7 @@ Every download Horizon makes is checked against the project's signature, includi
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
</details>
@@ -650,7 +650,7 @@ Far terrain drawn from the world you really built and explored, caves and builds
<details><summary>Technical</summary>
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
</details>
@@ -692,7 +692,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
</details>
@@ -704,7 +704,7 @@ Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible
<details><summary>Technical</summary>
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
@@ -748,7 +748,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
<details><summary>Technical</summary>
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
</details>
@@ -790,7 +790,7 @@ Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captur
<details><summary>Technical</summary>
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/horizon-rust/horizon/issues/562) asked for the F3 editor).
</details>
@@ -814,7 +814,7 @@ An optional ray-tracing mode for graphics cards that support it (RTX and recent
<details><summary>Technical</summary>
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/horizon-rust/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
</details>
@@ -824,7 +824,7 @@ Output for HDR monitors: brighter highlights and richer colour where your screen
<details><summary>Technical</summary>
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
@@ -858,7 +858,7 @@ Game and launcher builds for 64-bit ARM Linux computers.
<details><summary>Technical</summary>
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
</details>
@@ -868,7 +868,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
@@ -912,7 +912,7 @@ An optional AI teammate you can talk to by voice. It plays beside you, helps or
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/horizon-rust/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/horizon-rust/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
</details>
@@ -938,7 +938,7 @@ 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 (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).
`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/horizon-rust/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
@@ -952,7 +952,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
</details>
@@ -962,19 +962,19 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
</details>
## Recently shipped
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/horizon-rust/horizon/issues/1434)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/horizon-rust/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/horizon-rust/horizon/issues/1199) · [#1029](https://github.com/horizon-rust/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/horizon-rust/horizon/issues/1154) · [#1242](https://github.com/horizon-rust/horizon/issues/1242) · [#1243](https://github.com/horizon-rust/horizon/issues/1243) · [#1246](https://github.com/horizon-rust/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/horizon-rust/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/horizon-rust/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/horizon-rust/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
@@ -119,7 +119,7 @@ machines during the runs.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
@@ -148,7 +148,7 @@ early, wrote no report. Its lines come from its output.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+1 -1
View File
@@ -103,7 +103,7 @@ the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+4
View File
@@ -97,6 +97,9 @@ impl Report {
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::NativeMod {
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
}
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
@@ -118,6 +121,7 @@ impl Report {
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
+1 -1
View File
@@ -25,7 +25,7 @@ use std::{
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
+2 -2
View File
@@ -1,5 +1,5 @@
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! (`horizon-rust/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! workflow downloads the files and publishes what this writes.
use horizon_mods::index::{
FILE_HOST,
@@ -8,7 +8,7 @@ use horizon_mods::index::{
use std::path::Path;
/// Where the registry's site is published (its icons).
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
+28 -6
View File
@@ -1,6 +1,7 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
@@ -190,19 +191,31 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
_ => None,
};
}
// HoverEvent.ShowText.
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
&& e.get("action").and_then(Value::as_str) == Some("show_text")
&& let Some(value) = e.get("value")
{
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
// HoverEvent.ShowText and ShowItem.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
if let Some(value) = e.get("value") {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
}
style.insertion = o
.get("insertion")
.and_then(Value::as_str)
.map(str::to_owned);
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
@@ -287,6 +300,15 @@ pub fn to_text_with(
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -16,3 +16,20 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
/// characters, `minecraft` when the namespace is left out.
pub fn normalize_id(s: &str) -> Option<String> {
let (ns, path) = match s.split_once(':') {
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
None => ("minecraft", s),
};
let ok = |c: char, slash: bool| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| matches!(c, '_' | '-' | '.')
|| slash && c == '/'
};
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
.then(|| format!("{ns}:{path}"))
}
+2
View File
@@ -14,6 +14,7 @@ pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod nbt_value;
pub mod particle_type;
pub mod pattern;
pub mod potions;
@@ -23,4 +24,5 @@ pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub mod who;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -0,0 +1,32 @@
//! Reading and writing single NBT values the way vanilla's codecs accept them.
use horizon_world::nbt::Tag;
pub fn string(s: &str) -> Tag {
Tag::String(s.into())
}
pub fn text(tag: Option<&Tag>) -> Option<String> {
match tag {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
}
}
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
pub fn number(tag: Option<&Tag>) -> Option<i64> {
Some(match tag? {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
Tag::Float(b) => f32::from_bits(*b) as i64,
Tag::Double(b) => f64::from_bits(*b) as i64,
_ => return None,
})
}
/// Any numeric tag as f64, keeping a float's or double's fraction.
pub fn float(tag: Option<&Tag>) -> Option<f64> {
Some(match tag? {
Tag::Float(b) => f64::from(f32::from_bits(*b)),
Tag::Double(b) => f64::from_bits(*b),
other => number(Some(other))? as f64,
})
}
+24 -1
View File
@@ -47,6 +47,8 @@ pub enum ClickEvent {
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
ChangePage(i32),
SuggestCommand(String),
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
@@ -65,6 +67,10 @@ pub struct Style {
pub click: Option<ClickEvent>,
/// Hover text.
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
@@ -94,6 +100,12 @@ impl Style {
if self.hover.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
}
if self.font.is_none() {
self.font.clone_from(&base.font);
}
@@ -499,7 +511,7 @@ impl Text {
}
/// The style fields of a component's JSON.
const STYLE_KEYS: [&str; 9] = [
const STYLE_KEYS: [&str; 10] = [
"color",
"bold",
"italic",
@@ -508,6 +520,7 @@ const STYLE_KEYS: [&str; 9] = [
"obfuscated",
"click_event",
"hover_event",
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
@@ -541,6 +554,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
ClickEvent::CopyToClipboard(value) => {
json!({ "action": "copy_to_clipboard", "value": value })
}
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
@@ -550,6 +566,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"hover_event".to_owned(),
json!({ "action": "show_text", "value": hover.component() }),
));
} else if let Some(Value::Object(item)) = style.hover_item.as_deref() {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
}
if let Some(font) = &style.font {
out.push(("font".to_owned(), font.clone().into()));
+8
View File
@@ -0,0 +1,8 @@
//! Who an action came from or is aimed at: the player or a mob by id.
/// Who caused damage or is being targeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Who {
Player,
Mob(u32),
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "horizon-client-input"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[features]
controllers = ["dep:horizon-dualsense"]
[lints]
workspace = true
File renamed without changes.
@@ -145,6 +145,22 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.period", KeyCode::Period),
("key.keyboard.slash", KeyCode::Slash),
("key.keyboard.caps.lock", KeyCode::CapsLock),
("key.keyboard.world.1", KeyCode::IntlBackslash),
("key.keyboard.international1", KeyCode::IntlRo),
("key.keyboard.international3", KeyCode::IntlYen),
("key.keyboard.lang1", KeyCode::Lang1),
("key.keyboard.lang2", KeyCode::Lang2),
("key.keyboard.lang3", KeyCode::Lang3),
("key.keyboard.lang4", KeyCode::Lang4),
("key.keyboard.lang5", KeyCode::Lang5),
("key.keyboard.help", KeyCode::Help),
("key.keyboard.select", KeyCode::Select),
("key.keyboard.again", KeyCode::Again),
("key.keyboard.undo", KeyCode::Undo),
("key.keyboard.cut", KeyCode::Cut),
("key.keyboard.copy", KeyCode::Copy),
("key.keyboard.paste", KeyCode::Paste),
("key.keyboard.find", KeyCode::Find),
("key.keyboard.f1", KeyCode::F1),
("key.keyboard.f2", KeyCode::F2),
("key.keyboard.f3", KeyCode::F3),
@@ -157,6 +173,18 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.f10", KeyCode::F10),
("key.keyboard.f11", KeyCode::F11),
("key.keyboard.f12", KeyCode::F12),
("key.keyboard.f13", KeyCode::F13),
("key.keyboard.f14", KeyCode::F14),
("key.keyboard.f15", KeyCode::F15),
("key.keyboard.f16", KeyCode::F16),
("key.keyboard.f17", KeyCode::F17),
("key.keyboard.f18", KeyCode::F18),
("key.keyboard.f19", KeyCode::F19),
("key.keyboard.f20", KeyCode::F20),
("key.keyboard.f21", KeyCode::F21),
("key.keyboard.f22", KeyCode::F22),
("key.keyboard.f23", KeyCode::F23),
("key.keyboard.f24", KeyCode::F24),
("key.keyboard.print.screen", KeyCode::PrintScreen),
("key.keyboard.scroll.lock", KeyCode::ScrollLock),
("key.keyboard.pause", KeyCode::Pause),
@@ -188,6 +216,36 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.keypad.0", KeyCode::Numpad0),
("key.keyboard.keypad.period", KeyCode::NumpadDecimal),
("key.keyboard.keypad.equal", KeyCode::NumpadEqual),
("key.keyboard.keypad.comma", KeyCode::NumpadComma),
("key.keyboard.keypad.backspace", KeyCode::NumpadBackspace),
(
"key.keyboard.keypad.left.parenthesis",
KeyCode::NumpadParenLeft,
),
(
"key.keyboard.keypad.right.parenthesis",
KeyCode::NumpadParenRight,
),
("key.keyboard.keypad.hash", KeyCode::NumpadHash),
(
"key.keyboard.keypad.memory.store",
KeyCode::NumpadMemoryStore,
),
(
"key.keyboard.keypad.memory.recall",
KeyCode::NumpadMemoryRecall,
),
(
"key.keyboard.keypad.memory.clear",
KeyCode::NumpadMemoryClear,
),
("key.keyboard.keypad.memory.add", KeyCode::NumpadMemoryAdd),
(
"key.keyboard.keypad.memory.subtract",
KeyCode::NumpadMemorySubtract,
),
("key.keyboard.keypad.clear", KeyCode::NumpadClear),
("key.keyboard.keypad.clear.entry", KeyCode::NumpadClearEntry),
("key.keyboard.menu", KeyCode::ContextMenu),
("key.keyboard.left.control", KeyCode::ControlLeft),
("key.keyboard.left.shift", KeyCode::ShiftLeft),
+14
View File
@@ -0,0 +1,14 @@
//! The client's input layer, below gameplay and screens: key bindings and their
//! names, toggle keys, mouse settings, controllers, the clipboard and window
//! options. horizon-client re-exports each module under its old path
//! (`horizon_client::keybinds`, ...).
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod input;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod mouse;
pub mod window;
File renamed without changes.
+4 -2
View File
@@ -10,7 +10,9 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -54,7 +56,6 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
@@ -89,7 +90,7 @@ default = ["audio", "controllers"]
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["dep:cpal"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
# (release.yml); the tests and probes don't need it. Content mods load without it.
mods = ["horizon-mods/runtime"]
@@ -105,5 +106,6 @@ path = "examples/probe/main.rs"
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -2,7 +2,7 @@
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
#[allow(dead_code)]
#[path = "../src/blocks/decode.rs"]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
use std::{hint::black_box, path::Path, time::Instant};
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
match c["axis"].as_i64() {
Some(axis @ 0..=2) => {
let line = record("electric_spark");
particles::along_axis(&mut random, axis as usize, p, |p, v| {
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
lines.push(line(p, v))
});
}
@@ -24,6 +24,7 @@ fn stack(spec: &str) -> LootStack {
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -223,6 +223,7 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
display_health: int("displayHealth", 0),
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
health_blink_time: i64::from(int("healthBlinkTime", 0)),
..HealthHud::default()
};
let kind = if has("minecraft:poison") {
HeartKind::Poisoned
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
@@ -74,7 +74,14 @@ pub fn main() -> Result<()> {
biomes: &biomes,
registry: assets.registry,
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
gateway_particles: &|_, _| 0,
thundering: false,
ultrawarm: false,
};
engine.seed_randoms(1057, 0, 0);
engine.requested = Some(Vec::new());
@@ -7,6 +7,7 @@
use horizon_client::{
containers::{Furnace, FurnaceKind},
crafting::Recipes,
furnace::FurnaceCooking,
item_data::{canon_stack, parse_stack},
items::Items,
menu::{Env, Input, Kind, Menu},
@@ -567,7 +567,13 @@ fn print_heart(
/// The damage of a mob's direct hit on the player (others count as none).
fn player_damage(e: &MobEvent) -> f32 {
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::SonicBoom {
target: Who::Player,
..
} => 10.0,
_ => 0.0,
}
}
@@ -578,7 +584,13 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
return false;
};
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::SonicBoom {
target: Who::Player,
..
} => true,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius, _) => {
let d = Vec3::new(at[0], at[1], at[2])
@@ -1045,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
alive: true,
creative: s.creative,
sneaking: false,
swimming: false,
look: [0.0, 0.0, 1.0],
main_hand: s.hand.as_deref().map(Into::into),
off_hand: None,
@@ -1064,6 +1077,8 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
passenger: false,
}
});
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
)),
MotionEvent::Emission {
state,
in_water: true,
muffled: true,
..
} => Some(format!("muffled-step {}", state.0)),
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
+36 -1
View File
@@ -4,7 +4,7 @@
//! conditions advancements use).
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
};
@@ -753,6 +753,41 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
/// victim condition matched by a distinct victim, and the count of entity types.
pub fn killed_by_arrow(
c: &Value,
me: &EntityView,
victims: &[EntityView],
weapon: Option<&ItemView>,
w: &dyn World,
) -> bool {
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
Entities {
this: Some(e),
origin: me.position,
tool: None,
block_pos: None,
}
}
if !conditions(&c["player"], &ctx(me, me), w) {
return false;
}
if !c["fired_from_weapon"].is_null()
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
{
return false;
}
let mut left: Vec<&EntityView> = victims.iter().collect();
for want in c["victims"].as_array().into_iter().flatten() {
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
return false;
};
left.remove(i);
}
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
range_i(&c["unique_entity_types"], kinds.len() as i64)
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
+380 -2
View File
@@ -8,7 +8,7 @@ use crate::{
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
world::World,
};
use horizon_core::{
@@ -75,8 +75,32 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// A powered lever: its facing and the direction it is attached toward.
Lever {
facing: [i32; 3],
connected: [i32; 3],
},
/// A powered repeater.
Repeater {
facing: [i32; 3],
delay: i32,
},
/// A free-hanging stalactite tip that is not waterlogged.
PointedDripstone,
WitherRose,
EndGateway,
/// A lightning rod, by the axis it points along (0, 1, 2 for X, Y, Z).
LightningRod(usize),
/// `WetSpongeBlock.animateTick`.
WetSponge,
FireflyBush,
/// An open eyeblossom (`Type.OPEN.emitSounds()`).
OpenEyeblossom,
PaleHangingMoss,
/// `DryVegetationBlock`: a dead bush.
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -91,6 +115,7 @@ fn step(facing: &str) -> [i32; 3] {
fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let lit = prop("lit").as_deref() == Some("true");
let powered = prop("powered").as_deref() == Some("true");
let facing = || step(&prop("facing").unwrap_or_default());
match name {
"torch" => Animate::Torch(T::Flame),
@@ -172,10 +197,47 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"lever" if powered => Animate::Lever {
facing: facing(),
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
connected: match prop("face").as_deref() {
Some("ceiling") => [0, -1, 0],
Some("floor") => [0, 1, 0],
_ => facing(),
},
},
"repeater" if powered => Animate::Repeater {
facing: facing(),
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
},
"pointed_dripstone"
if prop("vertical_direction").as_deref() == Some("down")
&& prop("thickness").as_deref() == Some("tip")
&& prop("waterlogged").as_deref() != Some("true") =>
{
Animate::PointedDripstone
}
"wither_rose" => Animate::WitherRose,
"end_gateway" => Animate::EndGateway,
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
let [dx, dy, _] = facing();
Animate::LightningRod(if dx != 0 {
0
} else if dy != 0 {
1
} else {
2
})
}
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -246,10 +308,16 @@ pub struct AnimateTables {
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_logs: Vec<bool>,
/// `#triggers_ambient_desert_dry_vegetation_block_sounds`.
desert_dry_vegetation_sounds: Vec<bool>,
terracotta: Vec<bool>,
pale_oak_logs: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
/// Per state: 1 for a pointed dripstone, 2 for one whose tip points down.
dripstone: Vec<u8>,
}
impl AnimateTables {
pub fn load(root: &Path, registry: &BlockRegistry, biomes: &Biomes) -> crate::Result<Self> {
@@ -315,14 +383,36 @@ impl AnimateTables {
}
}
}
let dripstone = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(0, |s| {
let name = s.name.strip_prefix("minecraft:").unwrap_or(&s.name);
if name != "pointed_dripstone" {
0
} else if s.properties.get("vertical_direction").map(String::as_str)
== Some("down")
{
2
} else {
1
}
})
})
.collect();
Ok(Self {
kinds,
dripstone,
impermeable: by_state("minecraft:impermeable"),
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
desert_dry_vegetation_sounds: by_state(
"minecraft:triggers_ambient_desert_dry_vegetation_block_sounds",
),
terracotta: by_state("minecraft:terracotta"),
pale_oak_logs: by_state("minecraft:pale_oak_logs"),
burnable,
ambient,
})
@@ -348,8 +438,22 @@ pub struct AnimateContext<'a> {
pub registry: &'a BlockRegistry,
/// `Level.isRainingAt`.
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
/// `getHeight(MOTION_BLOCKING_NO_LEAVES, x, z)` at a position's column.
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
/// `BlockState.getOffset` of a state at (x, z), for blocks with a random model offset.
pub model_offset: &'a dyn Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>,
/// `TheEndGatewayBlockEntity.getParticleAmount` of the gateway state at a position.
pub gateway_particles: &'a dyn Fn(BlockStateId, [i32; 3]) -> usize,
/// `Level.isThundering`.
pub thundering: bool,
/// The dimension has `water_evaporates` and drips lava from dripstone (the Nether).
pub ultrawarm: bool,
}
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
@@ -398,6 +502,15 @@ impl AnimateContext<'_> {
],
))
}
/// `Level.getMaxLocalRawBrightness`: the brighter of block light and
/// sky light less the sky darkening.
fn max_local_raw_brightness(&self, p: [i32; 3]) -> i32 {
let light = self.world.light(p[0], p[1], p[2]);
i32::from(light & 15).max(i32::from(light >> 4) - self.sky_darken)
}
fn offset_of(&self, state: BlockStateId, p: [i32; 3]) -> [f64; 3] {
(self.model_offset)(state, p[0], p[2]).unwrap_or([0.0; 3])
}
fn foliage(&self, p: [i32; 3]) -> u32 {
let biome = self
.world
@@ -453,7 +566,7 @@ fn do_animate_tick(
return;
}
let state = ctx.state(pos);
block_animate_tick(tables, engine, ctx, state, pos);
block_animate_tick(tables, engine, ctx, state, pos, center);
let facts = ctx.facts.get(state);
if facts.fluid != Fluid::None {
fluid_animate_tick(
@@ -609,6 +722,22 @@ fn beehive_drip(
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayDesertDryVegetationBlockSounds`:
/// sand or terracotta two blocks deep.
fn desert_dry_vegetation_sounds(
tables: &AnimateTables,
ctx: &AnimateContext,
below: [i32; 3],
) -> bool {
let triggers = |p: [i32; 3]| {
tables
.desert_dry_vegetation_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
triggers(below) && triggers([below[0], below[1] - 1, below[2]])
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -684,6 +813,7 @@ fn block_animate_tick(
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
player: [i32; 3],
) {
let (fx, fy, fz) = (f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
match tables.kind(state) {
@@ -1073,12 +1203,198 @@ fn block_animate_tick(
}
}
}
Animate::FireflyBush => {
// FireflyBushBlock.animateTick: the idle sound on a quiet night
// under open sky, and fireflies in the dark within 5 blocks.
if next_int(engine.random(), 30) == 0
&& ctx.firefly_bush_sounds
&& (ctx.height_no_leaves)(pos) <= pos[1]
{
let name = "minecraft:block.firefly_bush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
if ctx.max_local_raw_brightness(pos) <= 13 {
let r = engine.random();
if r.next_double() <= 0.7 {
let p = [
fx + r.next_double() * 10.0 - 5.0,
fy + r.next_double() * 5.0,
fz + r.next_double() * 10.0 - 5.0,
];
add(engine, T::Firefly, p, [0.0; 3]);
}
}
}
Animate::OpenEyeblossom => {
// EyeblossomBlock.animateTick: pale moss below.
if next_int(engine.random(), 700) == 0 {
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
if ctx
.registry
.state(below)
.is_some_and(|s| s.name == "minecraft:pale_moss_block")
{
let name = "minecraft:block.eyeblossom.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::PaleHangingMoss => {
// HangingMossBlock.animateTick: under pale oak logs or leaves.
if next_int(engine.random(), 500) == 0 {
let above = ctx.state([pos[0], pos[1] + 1, pos[2]]);
let pale_leaves = ctx
.registry
.state(above)
.is_some_and(|s| s.name == "minecraft:pale_oak_leaves");
if pale_leaves
|| tables
.pale_oak_logs
.get(above.0 as usize)
.copied()
.unwrap_or(false)
{
let name = "minecraft:block.pale_hanging_moss.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
let below = [pos[0], pos[1] - 1, pos[2]];
let state = ctx.state(below);
let red_sand = ctx
.registry
.state(state)
.is_some_and(|s| s.name == "minecraft:red_sand");
let terracotta = tables
.terracotta
.get(state.0 as usize)
.copied()
.unwrap_or(false);
if (red_sand || terracotta) && next_int(engine.random(), 3) != 0 {
return;
}
if desert_dry_vegetation_sounds(tables, ctx, below) {
let name = "minecraft:block.deadbush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DryGrass => {
// AmbientDesertBlockSoundsPlayer.playAmbientDryGrassSounds:
// played at the player.
if next_int(engine.random(), 200) == 0
&& desert_dry_vegetation_sounds(tables, ctx, [pos[0], pos[1] - 1, pos[2]])
{
let at = [
f64::from(player[0]) + 0.5,
f64::from(player[1]),
f64::from(player[2]) + 0.5,
];
sound(engine, "minecraft:block.dry_grass.ambient", at, 1.0, 1.0);
}
}
Animate::EndPortal => {
// EndPortalBlock.animateTick: one smoke particle above the surface.
let r = engine.random();
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::Lever { facing, connected } => {
// LeverBlock.animateTick: a powered lever puffs red dust a quarter of the time.
if engine.random().next_float() < 0.25 {
// LeverBlock.makeParticle with scale 0.5.
let at = |base: f64, i: usize| {
base + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
};
let p = [at(fx, 0), at(fy, 1), at(fz, 2)];
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 0.5), p, [0.0; 3]);
}
}
Animate::Repeater { facing, delay } => {
// RepeaterBlock.animateTick: dust at one of the torches.
let r = engine.random();
let x = fx + 0.5 + (r.next_double() - 0.5) * 0.2;
let y = fy + 0.4 + (r.next_double() - 0.5) * 0.2;
let z = fz + 0.5 + (r.next_double() - 0.5) * 0.2;
let mut offset = -5.0f32;
if r.next_boolean() {
offset = (delay * 2 - 1) as f32;
}
offset /= 16.0;
let xo = f64::from(offset * facing[0] as f32);
let zo = f64::from(offset * facing[2] as f32);
redstone(engine, [x + xo, y, z + zo]);
}
Animate::PointedDripstone => dripstone_drip(tables, engine, ctx, state, pos),
Animate::WitherRose => {
// WitherRoseBlock.animateTick: smoke puffs at the centre of the shape.
let center = ctx.shape_y(pos).map_or([0.5; 2], |s| {
[(s.2[0] + s.2[1]) / 2.0, (s.2[2] + s.2[3]) / 2.0]
});
let offset = ctx.offset_of(state, pos);
let x = fx + center[0] + offset[0];
let z = fz + center[1] + offset[2];
for _ in 0..3 {
let r = engine.random();
if r.next_boolean() {
let p = [
x + r.next_double() / 5.0,
fy + (0.5 - r.next_double()),
z + r.next_double() / 5.0,
];
add(engine, T::Smoke, p, [0.0; 3]);
}
}
}
Animate::EndGateway => {
// EndGatewayBlock.animateTick: portal particles, one for each visible face.
for _ in 0..(ctx.gateway_particles)(state, pos) {
let r = engine.random();
let mut p = [
fx + r.next_double(),
fy + r.next_double(),
fz + r.next_double(),
];
let mut v = [
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
];
let flip = next_int(r, 2) * 2 - 1;
if r.next_boolean() {
p[2] = fz + 0.5 + 0.25 * f64::from(flip);
v[2] = f64::from(r.next_float() * 2.0 * flip as f32);
} else {
p[0] = fx + 0.5 + 0.25 * f64::from(flip);
v[0] = f64::from(r.next_float() * 2.0 * flip as f32);
}
add(engine, T::Portal, p, v);
}
}
Animate::LightningRod(axis) => {
// LightningRodBlock.animateTick: in a thunderstorm, sparks along the
// topmost rod, more often as the game time within 200 ticks grows.
if ctx.thundering
&& i64::from(next_int(engine.level_random(), 200)) <= ctx.world.game_time % 200
&& pos[1]
== ctx
.world
.world_surface(pos[0], pos[2], |id| ctx.facts.is_air(id))
- 1
{
let mut sparks = Vec::new();
along_axis(engine.level_random(), axis, pos, (1, 2), |p, v| {
sparks.push((p, v));
});
let options = ParticleOptions::simple(T::ElectricSpark);
for (p, v) in sparks {
engine.add(&options, p, v);
}
}
}
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
@@ -1382,6 +1698,68 @@ fn leaves_ambient_sound(
}
}
}
/// `PointedDripstoneBlock.animateTick`: a free-hanging stalactite tip drips the
/// fluid above the root of its stalactite.
fn dripstone_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
let roll = engine.random().next_float();
if roll > 0.12 {
return;
}
// getFluidAboveStalactite: findRootBlock climbs at most ten blocks of
// stalactite and stops at the first block that is not pointed dripstone.
let mut root = None;
for step in 1..11 {
let p = [pos[0], pos[1] + step, pos[2]];
match tables
.dripstone
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(0)
{
0 => {
root = Some(p);
break;
}
2 => {}
_ => return,
}
}
let Some(root) = root else {
return;
};
let above = ctx.facts.get(ctx.state([root[0], root[1] + 1, root[2]]));
// Mud above the root counts as a water source unless water evaporates here.
let (fluid, source) = if &*above.name == "minecraft:mud" && !ctx.ultrawarm {
(Fluid::Water, true)
} else {
(above.fluid, above.fluid_level >= 8 && !above.fluid_falling)
};
// canFillCauldron: only a water or lava source passes below a 2% roll.
if roll >= 0.02 && !(fluid != Fluid::None && source) {
return;
}
// getDripParticle: no fluid drips what the dimension's default says.
let lava = fluid == Fluid::Lava || (fluid == Fluid::None && ctx.ultrawarm);
let ty = if lava {
T::DrippingDripstoneLava
} else {
T::DrippingDripstoneWater
};
let offset = ctx.offset_of(state, pos);
// STALACTITE_DRIP_START_PIXEL is the tip's lowest point, 5/16.
let p = [
f64::from(pos[0]) + 0.5 + offset[0],
f64::from(pos[1]) + 0.3125 - 0.0625,
f64::from(pos[2]) + 0.5 + offset[2],
];
add(engine, ty, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
+3
View File
@@ -159,6 +159,8 @@ pub struct ArrowState {
pub pickup: Pickup,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
@@ -208,6 +210,7 @@ impl ArrowState {
last_state: None,
pickup: Pickup::Disallowed,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
pierce: 0,
pierced: Vec::new(),
+80
View File
@@ -218,6 +218,8 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
@@ -234,3 +236,81 @@ impl RenderAssets {
})
}
}
#[cfg(test)]
mod tests {
use crate::items::ConsumeEffect;
#[test]
fn always_edible_and_consume_effects_loaded() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
assert!(apple.food.is_some());
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
let enchanted = items
.get("minecraft:enchanted_golden_apple")
.expect("enchanted golden apple");
assert!(enchanted.can_always_eat);
assert!(enchanted.food.is_some());
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
assert!(!beef.can_always_eat);
assert!(beef.food.is_some());
// Consumables without food, their style and their remainders.
use horizon_world::consumable::UseAnimation;
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
let drink = milk.consumable.as_ref().unwrap();
assert!(milk.food.is_none());
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
assert!(!drink.particles);
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
let sip = honey.consumable.as_ref().unwrap();
assert_eq!(sip.ticks(), 40);
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
assert_eq!(
sip.effects,
[ConsumeEffect::RemoveEffects(vec![
"minecraft:poison".into()
])]
);
assert_eq!(
honey.use_remainder.as_deref(),
Some("minecraft:glass_bottle")
);
let kelp = items.get("minecraft:dried_kelp").unwrap();
let bite = kelp.consumable.as_ref().unwrap();
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
assert!(bite.particles && kelp.use_remainder.is_none());
let chorus = items.get("minecraft:chorus_fruit").unwrap();
assert_eq!(
chorus.consumable.as_ref().unwrap().effects,
[ConsumeEffect::TeleportRandomly(
horizon_world::teleport_consume::TeleportConsume {
diameter: 16.0,
directional_particles: true,
}
)]
);
let chicken = items.get("minecraft:chicken").unwrap();
assert!(matches!(
chicken.consumable.as_ref().unwrap().effects[..],
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
}
+1 -53
View File
@@ -3,24 +3,10 @@
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
//! six-layer limit and the result stack).
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BannerLayer {
pub pattern: Arc<str>,
pub color: Arc<str>,
}
impl BannerLayer {
pub fn new(pattern: &str, color: &str) -> Self {
Self {
pattern: Arc::from(pattern),
color: Arc::from(color),
}
}
}
pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt};
/// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`).
pub const MAX_LAYERS: usize = 6;
@@ -224,44 +210,6 @@ pub fn has_max_layers(banner: &ItemStack) -> bool {
layers(banner).len() >= MAX_LAYERS
}
/// The `banner_patterns` NBT list of layers.
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
Tag::List(
layers
.iter()
.map(|l| {
Tag::compound([
("pattern", Tag::String((&*l.pattern).into())),
("color", Tag::String((&*l.color).into())),
])
})
.collect(),
)
}
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
let Some(Tag::List(list)) = tag else {
return Vec::new();
};
list.iter()
.filter_map(|e| {
let text = |k: &str| match e.get(k) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
let pattern = text("pattern")?;
let color = text("color")?;
Some(BannerLayer::new(
&if pattern.contains(':') {
pattern
} else {
format!("minecraft:{pattern}")
},
&color,
))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+1 -14
View File
@@ -3,6 +3,7 @@
//! the rain, nor through a blocked front, unless the hive is emptied in an
//! emergency), honey for every bee coming home with nectar. The host (the game
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
pub use crate::chunk::block_entity::{Beehive, Occupant};
use crate::mobs::{Mob, Species, bee};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::nbt::{Compound, Tag};
@@ -38,20 +39,6 @@ const IGNORED_BEE_TAGS: [&str; 25] = [
"UUID",
];
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
/// `BeehiveBlockEntity.BeeReleaseStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Release {
+66 -3
View File
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
/// A `ConstantInt` count (sampling draws nothing).
pub constant: bool,
}
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
list.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let count = &e["count"];
let (min, max, constant) = match count {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c if c["type"].as_str() == Some("minecraft:constant") => {
let n = c["value"].as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
false,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
constant,
})
})
.collect()
}
/// `MobSpawnSettings.MobSpawnCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpawnCost {
pub energy_budget: f64,
pub charge: f64,
}
/// The `spawn_costs` of a `natural_mob_spawns` value.
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
costs
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| {
(
k.clone(),
SpawnCost {
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
charge: c["charge"].as_f64().unwrap_or(0.0),
},
)
})
.collect()
}
#[derive(Clone, Debug)]
pub struct BiomeInfo {
/// `natural_mob_spawns` by category (creature, monster, ...).
pub spawns: HashMap<String, Vec<SpawnEntry>>,
/// `natural_mob_spawns` spawn costs by entity type.
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
pub spawn_costs: HashMap<String, SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
@@ -66,6 +122,13 @@ impl BiomeInfo {
}
.height_adjusted(pos, sea_level)
}
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
/// without precipitation, else whether it falls as snow
/// (`getHeightAdjustedTemperature` below 0.15).
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
self.precipitation
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
pub struct Biomes {
pub list: Vec<BiomeInfo>,
@@ -143,12 +206,12 @@ impl Biomes {
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = crate::mobs::spawn_selection::parse_entries(entries);
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
+1 -1
View File
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::containers::BlockEntity;
use crate::chunk::block_entity::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
@@ -35,6 +35,25 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
}
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
/// permission: a spawn egg carries data of an op-only entity type
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
stack.item.ends_with("_spawn_egg")
&& matches!(
stack
.data()
.and_then(|d| d.other("minecraft:entity_data"))
.and_then(|data| data.get("id")),
Some(Tag::String(id)) if matches!(
id.to_string_lossy().as_str(),
"minecraft:falling_block"
| "minecraft:command_block_minecart"
| "minecraft:spawner_minecart"
)
)
}
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
/// spawners alike): the entity type a spawner item spawns.
+127 -17
View File
@@ -5,7 +5,11 @@
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{hud::Rect, mesher::Vertex, world::World};
use crate::{
hud::Rect,
mesher::{CULL_BACK, Vertex},
world::World,
};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_core::geometry::{
Direction,
@@ -93,6 +97,16 @@ pub fn diffuse(normal: Vec3, lights: (Vec3, Vec3)) -> f32 {
pub fn vertex_light(packed: u8) -> u32 {
(u32::from(packed >> 4) * 16) | ((u32::from(packed & 15) * 16) << 8)
}
/// `ChestRenderer.xmasTextures`: `SpecialDates.isExtendedChristmas` (24 to 26
/// December in the local time zone), read once when the renderer is created.
fn extended_christmas() -> bool {
use chrono::Datelike;
static XMAS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*XMAS.get_or_init(|| {
let now = chrono::Local::now();
now.month() == 12 && (24..=26).contains(&now.day())
})
}
fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
@@ -938,6 +952,30 @@ impl BlockEntityRenderer {
let lights = diffuse_lights(scene.nether);
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
}
/// `TheEndGatewayBlockEntity.shouldRenderFace` for each face of the gateway
/// at `pos`: the faces its portal is drawn on.
pub fn gateway_faces(
&self,
world: &World,
state: BlockStateId,
pos: [i32; 3],
) -> Vec<Direction> {
Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(state, neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect()
}
/// Whether this block state has a block-entity renderer here.
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
@@ -1145,11 +1183,25 @@ impl BlockEntityRenderer {
}
}
Kind::Chest {
block,
texture,
part,
y_rot,
..
} => {
// ChestRenderer.getChestMaterial: normal and trapped chests use the christmas
// sprites on 24-26 December. Copper and ender chests keep theirs.
let texture = if matches!(block.as_str(), "chest" | "trapped_chest")
&& extended_christmas()
{
match part {
ChestPart::Single => "entity/chest/christmas",
ChestPart::Left => "entity/chest/christmas_left",
ChestPart::Right => "entity/chest/christmas_right",
}
} else {
*texture
};
let model = match part {
ChestPart::Single => "chest",
ChestPart::Left => "double_chest_left",
@@ -1392,6 +1444,7 @@ impl BlockEntityRenderer {
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
}
}
let first = out.len();
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
@@ -1409,6 +1462,11 @@ impl BlockEntityRenderer {
draw_part(part, side, m, &none, paint, out);
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each.
for vertex in &mut out[first..] {
vertex.normal = CULL_BACK;
}
}
Kind::EndPortal => {
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
@@ -1439,22 +1497,8 @@ impl BlockEntityRenderer {
out,
);
}
// AbstractEndPortalRenderer.extractRenderState: TheEndGatewayBlockEntity.shouldRenderFace.
let faces: Vec<Direction> = Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = scene.world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(BlockStateId(*state), neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect();
// AbstractEndPortalRenderer.extractRenderState.
let faces = self.gateway_faces(scene.world, BlockStateId(*state), pos);
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
}
Kind::Conduit => {
@@ -2118,4 +2162,70 @@ mod tests {
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
/// boxes, so without that cull both sides of each plane are drawn and they
/// z-fight: the pot renders wrong.
#[test]
fn the_decorated_pot_culls_its_back_faces() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-models.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
let state = registry.default_state("minecraft:decorated_pot").unwrap();
let sprites: HashMap<String, Rect> = [
"entity/decorated_pot/decorated_pot_base",
"entity/decorated_pot/decorated_pot_side",
]
.into_iter()
.map(|name| {
(
name.to_owned(),
Rect {
u0: 0.25,
v0: 0.125,
u1: 0.375,
v1: 0.25,
w: 16,
h: 16,
},
)
})
.collect();
let world = World::new(BlockStateId(0), 1);
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.5,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
assert_eq!(
verts.len() / 4,
28,
"the pot submits all of vanilla's quads"
);
assert!(
verts.iter().all(|v| v.normal == CULL_BACK),
"entitySolid culls the pot's back faces"
);
}
}
+3 -14
View File
@@ -2,12 +2,13 @@
//! in vanilla, ported as tables), data-driven `minecraft:brewing` recipes from
//! `data/minecraft/recipe/brewing`, potion colours and tooltips, and
//! `BrewingStandBlockEntity.serverTick`.
pub use crate::chunk::block_entity::BrewingStand;
use crate::{
containers::Providers,
items::{ItemStack, Items},
providers::Providers,
};
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path, sync::Arc};
use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
@@ -190,18 +191,6 @@ impl Brewing {
}
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// What a brewing tick asks the world to do.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BrewTick {
+21
View File
@@ -772,6 +772,27 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
pose
}
/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's
/// projection after the bob: a skew about a tilted axis, from a portal's or
/// Nausea's intensity (already scaled by Distortion Effects) and the angle in
/// degrees. The first-person hand keeps its own projection without it.
pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 {
if intensity <= 0.0 {
return glam::Mat4::IDENTITY;
}
let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04;
skew *= skew;
let axis = glam::Vec3::new(
0.0,
std::f32::consts::SQRT_2 / 2.0,
std::f32::consts::SQRT_2 / 2.0,
);
let angle = angle_degrees * DEG_TO_RAD;
glam::Mat4::from_axis_angle(axis, angle)
* glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0))
* glam::Mat4::from_axis_angle(axis, -angle)
}
#[cfg(test)]
mod tests {
#[test]
+26 -11
View File
@@ -556,26 +556,34 @@ impl Chat {
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
self.style_at(font, height, x, y)?.hover.map(|h| *h)
}
/// ChatComponent.getClickedComponentStyleAt.
fn style_at(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> Option<crate::text::Style> {
/// The wrapped line under a GUI-space cursor while chat is focused, and
/// the cursor's x within it (the text starts at 0).
fn line_at(&self, height: i32, x: f64, y: f64) -> Option<(&Line, f64)> {
let scale = self.options.scale as f32;
let mut x = x / f64::from(scale) - 4.0;
let x = x / f64::from(scale) - 4.0;
let bottom = hud_layout::floor((height - 40) as f32 / scale);
let from_bottom = f64::from(bottom) - y / f64::from(scale);
if x < 0.0 || x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
if x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
return None;
}
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
if row >= self.lines_per_page(true) {
return None;
}
let line = self.lines.get(self.scroll + row)?;
Some((self.lines.get(self.scroll + row)?, x))
}
/// ChatComponent.getClickedComponentStyleAt.
pub fn style_at(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> Option<crate::text::Style> {
let (line, mut x) = self.line_at(height, x, y)?;
if x < 0.0 {
return None;
}
let content = line.content.resolve_legacy();
for (c, style) in content.chars() {
x -= f64::from(font.advance(c, style));
@@ -585,6 +593,13 @@ impl Chat {
}
None
}
/// Whether the cursor is on a line's indicator bar while chat is focused
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
/// message tag's text.
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
self.line_at(height, x, y)
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
self.messages
+3 -1
View File
@@ -6,6 +6,8 @@ use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::{Arc, OnceLock};
pub mod block_entity;
pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
@@ -149,7 +151,7 @@ pub struct Chunk {
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), crate::containers::BlockEntity)>,
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
pub block_names: Vec<((i32, i32, i32), String)>,
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
@@ -0,0 +1,409 @@
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
//! and its payloads). Plain data, as vanilla's block entities are world data;
//! their behaviour lives with the features that use them (`containers`,
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
use horizon_client_core::{text::Text, who::Who};
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{collections::HashMap, sync::Arc};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
/// by (`ResolvableProfile.nameOrId`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum NameOrId {
Name(String),
Id(u128),
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// One sign side (`SignText`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignText {
pub lines: [String; 4],
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SignData {
pub hanging: bool,
pub front: SignText,
pub back: SignText,
pub waxed: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Info {
pub event: &'static str,
pub distance: f32,
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
pub(crate) current: Option<(Info, i64)>,
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SculkKind {
Sensor,
Calibrated,
Shrieker,
Catalyst,
}
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
#[derive(Clone, Debug)]
pub struct SculkEntity {
pub kind: SculkKind,
pub vibration: Data,
/// `last_vibration_frequency` (sensors).
pub last_frequency: i32,
/// `warning_level` (shriekers).
pub warning_level: i32,
/// The catalyst's charge cursors.
pub spreader: SculkSpreader,
}
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
/// must have (inclusive ranges within 0..=15).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CustomSpawnRules {
pub block: (i32, i32),
pub sky: (i32, i32),
}
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
/// table (kept as loaded).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpawnData {
pub entity: Compound,
pub custom_spawn_rules: Option<CustomSpawnRules>,
pub equipment: Option<Tag>,
}
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Next {
Potential(usize),
Own(SpawnData),
}
/// `BaseSpawner`.
#[derive(Clone, Debug, PartialEq)]
pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
pub(crate) next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
pub max_nearby_entities: i32,
pub required_player_range: i32,
pub spawn_range: i32,
/// The client copy's delay (`spawnDelay` on the client level), which
/// speeds the spin up as the next spawn nears.
pub client_delay: i32,
pub spin: f64,
pub o_spin: f64,
}
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
pub spawn_range: i32,
pub total_mobs: f32,
pub simultaneous_mobs: f32,
pub total_mobs_added_per_player: f32,
pub simultaneous_mobs_added_per_player: f32,
pub ticks_between_spawn: i32,
pub spawn_potentials: Vec<(SpawnData, i32)>,
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ConfigSlot {
/// The value as loaded, written back as is; None for the default.
pub saved: Option<Tag>,
pub config: TrialConfig,
}
/// `TrialSpawner` with its `TrialSpawnerStateData`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialSpawner {
pub normal: ConfigSlot,
pub ominous: ConfigSlot,
pub target_cooldown_length: i32,
pub required_player_range: i32,
pub detected_players: Vec<Uuid>,
pub current_mobs: Vec<Uuid>,
pub cooldown_ends_at: i64,
pub next_mob_spawns_at: i64,
pub total_mobs_spawned: i32,
pub next_spawn_data: Option<SpawnData>,
pub ejecting_loot_table: Option<String>,
/// `isOminous`, set from the block on every server tick.
pub is_ominous: bool,
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
/// made once per loaded block entity and kept.
pub has_display: bool,
pub spin: f64,
pub o_spin: f64,
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<ItemStack>>,
}
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
pub loot_table: String,
pub activation_range: f64,
pub deactivation_range: f64,
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
pub key_item: Option<ItemStack>,
pub override_loot_table_to_display: Option<String>,
}
/// `VaultBlockEntity`: its config, server data and shared data.
#[derive(Clone, Debug, PartialEq)]
pub struct Vault {
pub config: VaultConfig,
/// `VaultServerData`, oldest rewarded player first.
pub rewarded_players: Vec<Uuid>,
pub state_updating_resumes_at: i64,
/// Popped from the end.
pub items_to_eject: Vec<ItemStack>,
pub total_ejections_needed: i32,
pub last_insert_fail_timestamp: i64,
/// `VaultSharedData`.
pub display_item: Option<ItemStack>,
pub connected_players: Vec<Uuid>,
pub connected_particles_range: f64,
/// The shared data changed since the last tick sent it.
pub shared_dirty: bool,
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
pub spin: f32,
pub o_spin: f32,
}
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
+6 -20
View File
@@ -1,8 +1,10 @@
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
//! count comparators read, the output it keeps, and the powered, automatic
//! and condition flags `CommandBlock.tick` runs on.
use crate::{
save::{number, text},
pub use crate::chunk::block_entity::CommandBlock;
use horizon_client_core::{
nbt_value::{number, text},
scoreboard,
text::Text,
};
use horizon_world::nbt::Tag;
@@ -76,22 +78,6 @@ pub fn automatic_block(name: &str) -> bool {
name == "minecraft:chain_command_block"
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
impl Default for CommandBlock {
fn default() -> Self {
Self {
@@ -123,7 +109,7 @@ impl CommandBlock {
last_output: track_output
.then(|| tag.get("LastOutput"))
.flatten()
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
.map(|t| scoreboard::comp_text(&scoreboard::tag_to_json(t))),
update_last_execution,
last_execution: if update_last_execution {
number(tag.get("LastExecution")).unwrap_or(-1)
@@ -179,7 +165,7 @@ impl CommandBlock {
pub fn name(&self) -> Text {
self.custom_name.as_ref().map_or_else(
|| Text::literal("@"),
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|t| scoreboard::comp_text(&scoreboard::tag_to_json(t)),
)
}
}
+13 -2
View File
@@ -63,13 +63,24 @@ impl Confirm {
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
) {
self.draw_active(batch, atlas, cursor, labels, true);
}
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
pub fn draw_active(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
active: bool,
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
}
}
}
+6 -452
View File
@@ -1,19 +1,13 @@
//! Container block entities: chests/barrels (27 slots) and the three furnace
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
use crate::items::ItemStack;
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
@@ -34,21 +28,6 @@ impl FurnaceKind {
}
}
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
@@ -63,78 +42,6 @@ pub fn recipe_used_key(id: &str) -> String {
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<crate::brewing::BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(crate::signs::SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<crate::sculk::SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<crate::mobs::spawner::Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<crate::vault::Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
@@ -182,17 +89,6 @@ impl Campfire {
changed
}
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
impl Skull {
pub fn new(
profile: Option<horizon_world::nbt::Tag>,
@@ -208,18 +104,6 @@ impl Skull {
}
}
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<crate::skins::NameOrId>,
pub fallback: &'static str,
}
impl SkullSkin {
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
@@ -248,18 +132,6 @@ impl SkullSkin {
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
impl Crafter {
/// `slotCanBeDisabled`: only empty slots.
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
@@ -299,48 +171,6 @@ impl Crafter {
})
}
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<crate::banner::BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
impl BlockEntity {
pub fn for_block(name: &str) -> Option<Self> {
if let Some(hanging) = crate::signs::sign_kind(name) {
@@ -546,283 +376,7 @@ impl BlockEntity {
}
}
}
/// Evaluator for `context_int_provider` / `context_float_provider` values.
pub struct Providers {
ints: HashMap<String, Value>,
floats: HashMap<String, Value>,
}
fn load_dir(dir: &Path, prefix: &str, out: &mut HashMap<String, Value>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
if p.is_dir() {
load_dir(&p, &format!("{prefix}{name}/"), out);
} else if let Ok(bytes) = fs::read(&p)
&& let Ok(v) = serde_json::from_slice(&bytes)
{
out.insert(format!("minecraft:{prefix}{name}"), v);
}
}
}
impl Providers {
pub fn load(root: &Path) -> Self {
let mut ints = HashMap::new();
let mut floats = HashMap::new();
load_dir(
&root.join("data/minecraft/context_int_provider"),
"",
&mut ints,
);
load_dir(
&root.join("data/minecraft/context_float_provider"),
"",
&mut floats,
);
Self { ints, floats }
}
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
if depth > 16 {
return 0.0;
}
match v {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(name) => table
.get(name)
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
Value::Object(o) => {
let f =
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
match o.get("type").and_then(Value::as_str).unwrap_or("") {
"minecraft:div" => {
let r = f("right");
if r == 0.0 {
0.0
} else {
(f("left") / r).trunc()
}
}
"minecraft:mul" => f("left") * f("right"),
"minecraft:add" | "minecraft:sum" => f("left") + f("right"),
"minecraft:sub" => f("left") - f("right"),
"minecraft:constant" => f("value"),
"minecraft:conditional" => {
// The only condition in the pinned data: block/fast_cooking.
let condition = o.get("condition").and_then(Value::as_str)
== Some("minecraft:block/fast_cooking");
if condition && fast {
f("on_true")
} else {
f("on_false")
}
}
_ => 0.0,
}
}
_ => 0.0,
}
}
/// A plain `context_int_provider` value or reference (no context conditions).
pub fn int_value(&self, v: &Value) -> i32 {
Self::eval(v, false, &self.ints, 0) as i32
}
/// A plain `context_float_provider` value or reference; `default` when absent.
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
if v.is_null() {
default
} else {
Self::eval(v, false, &self.floats, 0) as f32
}
}
#[cfg(test)]
pub fn empty() -> Self {
Self {
ints: HashMap::new(),
floats: HashMap::new(),
}
}
pub fn burn_time(&self, items: &Items, stack: &ItemStack, fast: bool) -> i32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
}
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(1.0, |(_, speed)| {
Self::eval(speed, fast, &self.floats, 0) as f32
})
}
}
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
}
impl Furnace {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
}
}
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
pub fn tick(
&mut self,
recipes: &Recipes,
items: &Items,
providers: &Providers,
) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
}
}
pub use crate::providers::Providers;
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
/// more with the chance of what is left over (a level random draw only when there is some).
+25 -3
View File
@@ -355,6 +355,19 @@ impl Creative {
pub fn search_items(&self) -> &[ItemStack] {
&self.search_items
}
/// Opening the screen: vanilla rebuilds CreativeModeInventoryScreen, whose
/// init() makes a fresh search box and a zeroed scrollbar; only the tab
/// persists (its static selectedTab). selectTab on the reopened screen
/// takes the oldTab != tab branch, so it empties and refocuses the box.
pub fn reopen(&mut self) {
self.search.set_value("");
self.release();
self.scroll = 0.0;
self.search.focused = self.tab().search;
if self.search.focused {
self.search.reset_blink();
}
}
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
/// the name tree, both over vanilla's suffix arrays (substring matches,
@@ -575,6 +588,8 @@ pub enum DisplayClick {
Nothing,
/// SWAP: the hotbar slot gets a full stack of the item.
Hotbar(usize, ItemStack),
/// SWAP with button 40 (Key F): the off-hand gets a full stack of the item.
Offhand(ItemStack),
/// THROW: a copy of the item is dropped.
Drop(ItemStack),
}
@@ -596,8 +611,11 @@ pub fn click_display(
match input {
Input::Swap => {
return match shown {
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
None => DisplayClick::Nothing,
Some(s) if button == 40 => DisplayClick::Offhand(copy(s, max(s))),
Some(s) if (0..9).contains(&button) => {
DisplayClick::Hotbar(button as usize, copy(s, max(s)))
}
Some(_) | None => DisplayClick::Nothing,
};
}
Input::Clone => {
@@ -720,11 +738,15 @@ mod tests {
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
Some(stack("ender_pearl", 8))
);
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
// Number keys fill a hotbar slot, F fills off-hand, Q drops copies, pick block clones.
assert_eq!(
display(None, 3, Swap).1,
DisplayClick::Hotbar(3, stack("stone", 64))
);
assert_eq!(
display(None, 40, Swap).1,
DisplayClick::Offhand(stack("stone", 64))
);
assert_eq!(
display(None, 0, Throw).1,
DisplayClick::Drop(stack("stone", 1))
+8 -3
View File
@@ -29,6 +29,10 @@ impl EffectInstance {
fn infinite(&self) -> bool {
self.duration == -1
}
/// `MobEffectInstance.endsWithin`.
pub fn ends_within(&self, ticks: i32) -> bool {
!self.infinite() && self.duration <= ticks
}
fn shorter_than(&self, other: &Self) -> bool {
!self.infinite() && (self.duration < other.duration || other.infinite())
}
@@ -129,15 +133,16 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
_ => None,
}
}
/// `InstantenousMobEffect.applyInstantenousEffect` (potions apply these directly).
/// `HealOrHarmMobEffect.applyInstantaneousEffect` (potions apply these directly;
/// the amount rounds to the nearest whole point).
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30))) as i32 as f32,
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30))) as i32 as f32,
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
+8 -1
View File
@@ -19,6 +19,8 @@ pub struct ItemEntity {
pub id: u32,
pub stack: ItemStack,
pub thrower: Option<[i32; 4]>,
/// `ItemEntity.target`: the only player who may pick the item up.
pub target: Option<[i32; 4]>,
pub position: Vec3,
pub previous: Vec3,
pub velocity: Vec3,
@@ -355,7 +357,10 @@ pub fn merge_with_neighbours(
}
// ItemEntity.areMergable: both fit one stack of the same item and components.
let (this, other) = (&items[i].stack, &items[j].stack);
if this.count + other.count > max_stack(other) || !this.same_item_same_components(other) {
if items[i].target != items[j].target
|| this.count + other.count > max_stack(other)
|| !this.same_item_same_components(other)
{
continue;
}
let (to, from) = if other.count < this.count {
@@ -951,6 +956,7 @@ mod fire_environment_tests {
let make = |id: &str| ItemEntity {
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count: 1,
@@ -1047,6 +1053,7 @@ mod merge_tests {
ItemEntity {
id: 0,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count,
@@ -189,6 +189,7 @@ mod tests {
extra: None,
},
thrower: None,
target: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
+7 -2
View File
@@ -54,6 +54,8 @@ struct Out {
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -102,7 +104,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
// minecraft_mix_light: the vertex colour times the cardinal light only.
var c = color;
var back = c;
if normal.w != 0.0 {
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
}
@@ -234,7 +239,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 { discard; }
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
+52 -13
View File
@@ -13,7 +13,6 @@ use crate::{
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
uuid_from_tag,
},
text::Text,
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
@@ -515,6 +514,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
} else {
map.remove(&"Owner".into());
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
}
"minecraft:salmon" => {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
@@ -552,7 +557,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"memories",
Tag::compound([(
"minecraft:liked_player",
Tag::compound([("value", Tag::IntArray(vec![0, 0, 0, 1]))]),
Tag::compound([(
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
)]),
)]),
);
@@ -606,6 +614,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
}
}
"minecraft:endermite" => {
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
}
"minecraft:shulker" => {
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
@@ -712,6 +723,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// A wolf's armor keeps the durability it lost.
if slot == "body"
&& m.species() == crate::mobs::Species::Wolf
&& let Some(id) = item
{
let mut stack = crate::items::ItemStack::new(id, 1);
stack.damage = m.extra.body_damage.max(0) as u32;
stack.extra = m.extra.body_extra.clone();
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
match item {
Some(id) => {
let keep = matches!(
@@ -851,14 +873,7 @@ fn custom_name(tag: &Tag) -> Option<String> {
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
pub use horizon_client_core::component::name_text;
/// 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 {
@@ -1111,6 +1126,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.variant = gene("MainGene");
m.extra.variant2 = gene("HiddenGene");
}
if m.species() == crate::mobs::Species::Parrot {
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");
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
@@ -1118,6 +1138,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
crate::mobs::tame::set_tame(&mut m, tame);
m.health = health;
m.extra.sitting = flag(tag, "Sitting");
let armor = tag
.get("equipment")
.and_then(|e| e.get("body"))
.and_then(crate::save::item_from_nbt);
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
m.extra.body_extra = armor.and_then(|s| s.extra);
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
}
@@ -1148,11 +1174,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
}
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
m.extra.tame = tag
let liked = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:liked_player"))
.is_some();
.and_then(|b| b.get("minecraft:liked_player"));
m.extra.tame = liked.is_some();
m.extra.owner = liked
.and_then(|l| l.get("value"))
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
}
if crate::mobs::camel::is_camel(m.species()) {
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
@@ -1230,6 +1259,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Endermite {
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Vex {
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
&& v.len() == 3
@@ -1320,6 +1352,11 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Thrower".into());
}
if let Some(target) = e.target {
put(&mut tag, "Owner", Tag::IntArray(target.to_vec()));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Owner".into());
}
tag
}
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
@@ -1331,6 +1368,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
id,
stack,
thrower: uuid_from_tag(tag.get("Thrower")),
target: uuid_from_tag(tag.get("Owner")),
position,
previous: position,
velocity: motion(tag),
@@ -2240,6 +2278,7 @@ mod tests {
health: 2,
fire_ticks: 73,
thrower: Some([5, 6, 7, 8]),
target: None,
id: 7,
stack: ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
+18 -12
View File
@@ -26,6 +26,13 @@ pub enum ItemPose {
Bow {
ticks_used: f32,
},
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
/// the ticks since its last hit's feedback.
Spear {
kinetic: crate::spear::Kinetic,
time_held: f32,
ticks_since_hit: f32,
},
Crossbow {
ticks_used: Option<f32>,
charge_duration: f32,
@@ -69,6 +76,16 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
// The spear's own place in the hand, without the equip height.
if let ItemPose::Spear {
kinetic,
time_held,
ticks_since_hit,
} = pose
{
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
}
if matches!(pose, ItemPose::SpinAttack) {
return m
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
@@ -426,18 +443,7 @@ impl UseResult {
matches!(self, Self::Success { swing: true }) && before == after
}
}
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
use horizon_world::consumable::UseAnimation;
match v
.get("animation")
.and_then(serde_json::Value::as_str)
.unwrap_or("eat")
{
"eat" => UseAnimation::Eat,
"drink" => UseAnimation::Drink,
_ => UseAnimation::None,
}
}
pub use horizon_items::use_animation::consumable_animation;
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
+173
View File
@@ -0,0 +1,173 @@
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
use crate::{
containers::{Furnace, recipe_used_key},
crafting::Recipes,
items::{ItemStack, Items},
providers::Providers,
};
use std::sync::Arc;
/// The furnace methods that read the recipes.
pub trait FurnaceCooking {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
}
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
}
impl FurnaceCooking for Furnace {
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
}
}
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
}
}
File diff suppressed because it is too large. Load diff
@@ -719,6 +719,19 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
victims: &[EntityView],
weapon: Option<&ItemStack>,
) {
let me = self.player_entity();
let victims = victims.to_vec();
let weapon = weapon.map(item_view);
self.trigger("minecraft:killed_by_arrow", move |c, w| {
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), w)
});
}
/// `LevitationTrigger`: how far the player rose from where it started
/// levitating, and for how long.
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
@@ -863,6 +876,35 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
self.trigger("minecraft:construct_beacon", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w) && adv::range_i(&c["level"], i64::from(levels))
});
}
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
let me = self.player_entity();
let it = item_view(stack);
self.trigger("minecraft:enchanted_item", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::item(&c["item"], &it, w)
&& adv::range_i(&c["levels"], i64::from(levels))
});
}
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
/// brewing stand.
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
@@ -951,6 +993,42 @@ impl Game<'_> {
block_ok && state_ok && adv::conditions(&c["player"], &pctx, w)
});
}
/// `SlideDownBlockTrigger`: match the actual block and its state.
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
let me = self.player_entity();
self.trigger("minecraft:slide_down_block", move |c, w| {
let ctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
});
}
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
/// the shooter's context, including distance from the shooter.
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
let me = self.player_entity();
let projectile = projectile.clone();
self.trigger("minecraft:target_hit", move |c, w| {
let player = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let target = adv::Entities {
this: Some(&projectile),
..player
};
adv::conditions(&c["player"], &player, w)
&& adv::range_i(&c["signal_strength"], i64::from(strength))
&& adv::conditions(&c["projectile"], &target, w)
});
}
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
let me = self.player_entity();
+38 -3
View File
@@ -8,7 +8,6 @@ use horizon_core::random::RandomSource;
impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
let player = self.player.as_ref().map(|p| p.position);
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
@@ -58,7 +57,7 @@ impl Game<'_> {
};
let Some((j, _)) = wanted else {
self.mobs[i].extra.point = None;
self.allay_deliver(i, player);
self.allay_deliver(i);
continue;
};
let e = &self.dropped[j];
@@ -90,14 +89,46 @@ impl Game<'_> {
}
}
}
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
/// found by UUID among the host and the guests of the level (the window
/// is a guest on the server thread), when they are not a spectator and
/// within 64 blocks.
fn allay_liked_player(&self, i: usize) -> Option<horizon_core::geometry::Vec3> {
let m = &self.mobs[i];
if !m.extra.tame {
return None;
}
// An allay saved before its player was kept belongs to the host.
let uuid = m
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1]);
let host = self
.player_view()
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
.map(|p| p.position);
let found = host.or_else(|| {
self.lan_present()
.into_iter()
.find(|p| p.uuid == uuid && !p.spectator)
.map(|p| {
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
})
});
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
}
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
/// the deposit, one item a tick, each restarting the three-second pause
/// before it picks anything up again.
fn allay_deliver(&mut self, i: usize, player: Option<horizon_core::geometry::Vec3>) {
fn allay_deliver(&mut self, i: usize) {
let m = &self.mobs[i];
if m.extra.ints[1] <= 0 {
return;
}
let player = self.allay_liked_player(i);
let m = &self.mobs[i];
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
Some(h) => horizon_core::geometry::Vec3::new(
f64::from(h[0]) + 0.5,
@@ -167,11 +198,14 @@ impl Game<'_> {
self.consume_held();
return true;
}
let acting = self.acting_uuid();
let holds = m.extra.main_hand.is_some();
if !holds && !item.is_empty() {
let m = &mut self.mobs[index];
m.extra.main_hand = Some(item.into());
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
self.audio
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
self.consume_held();
@@ -186,6 +220,7 @@ impl Game<'_> {
.unwrap_or_else(|| "minecraft:air".into());
let carried = std::mem::take(&mut m.extra.ints[1]);
m.extra.tame = false;
m.extra.owner = None;
self.audio
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
if carried > 0 {
+33 -1
View File
@@ -164,11 +164,35 @@ impl Game<'_> {
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
} else if was && !is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
}
}
}
/// `BeaconBlockEntity.tick` after activating: `CONSTRUCT_BEACON` for a
/// player in the box `AABB(x, y, z, x, y - 4, z).inflate(10, 5, 10)`.
fn beacon_constructed(&mut self, p: [i32; 3], levels: i32) {
let Some(bb) = self
.player
.as_ref()
.map(|pl| pl.dimensions.bounds(pl.position))
else {
return;
};
let (lo, hi) = (bb.min_position(), bb.max_position());
let [x, y, z] = p.map(f64::from);
if lo.x < x + 10.0
&& hi.x > x - 10.0
&& lo.y < y + 5.0
&& hi.y > y - 9.0
&& lo.z < z + 10.0
&& hi.z > z - 10.0
{
self.trigger_construct_beacon(levels);
}
}
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
let at = [
f64::from(p[0]) + 0.5,
@@ -539,13 +563,21 @@ impl Game<'_> {
};
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
let scoping = self.scoping();
let view = f64::from(self.settings.render_distance * 16);
for (k, e) in &self.world.block_entities {
let BlockEntity::Beacon(b) = e else { continue };
// `getBeamSections` is empty while Levels is 0.
if b.levels == 0 || b.sections.is_empty() {
continue;
}
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
let distance = (base - camera).truncate().length() as f32;
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
let offset = base - camera;
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
if horizontal >= view {
continue;
}
let distance = horizontal as f32;
let scale = if scoping {
1.0
} else {
@@ -9,6 +9,7 @@ use horizon_client::{
Result,
block_entities::{Pos, Ticking},
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
furnace::FurnaceCooking,
};
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
+42 -2
View File
@@ -4,9 +4,13 @@
//! placed item gives its block entity.
use super::Game;
use horizon_client::{
block_entity_item, block_entity_nbt, containers::BlockEntity, item_data, items::ItemStack, save,
Result, block_entity_item, block_entity_nbt, containers::BlockEntity, item_data,
items::ItemStack, save,
};
use horizon_world::{
nbt::{Compound, Tag},
registry::BlockStateId,
};
use horizon_world::nbt::{Compound, Tag};
/// The component patch of `stack`.
fn components(stack: &ItemStack) -> Compound {
@@ -77,6 +81,42 @@ impl Game<'_> {
Some(stack)
}
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
/// `block_state` component (a hive's honey level, a statue's pose) set on
/// the block just placed at `pos`. Returns the state it has now.
pub(super) fn apply_item_block_state(
&mut self,
pos: [i32; 3],
state: BlockStateId,
stack: &ItemStack,
) -> Result<BlockStateId> {
let Some(Tag::Compound(saved)) =
stack.data().and_then(|d| d.other("minecraft:block_state"))
else {
return Ok(state);
};
let Some(current) = self.pack.assets.registry.state(state) else {
return Ok(state);
};
// Only the properties the block has.
let props: Vec<(String, String)> = saved
.iter()
.filter_map(|(k, v)| match v {
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
_ => None,
})
.filter(|(k, v)| current.properties.get(k).is_some_and(|old| old != v))
.collect();
let props: Vec<(&str, &str)> = props.iter().map(|(k, v)| (&**k, &**v)).collect();
match self.with_props(state, &props) {
Some(new) if !props.is_empty() && new != state => {
self.level_set(pos, new)?;
Ok(new)
}
_ => Ok(state),
}
}
/// `BlockItem.updateCustomBlockEntityTag` and `updateBlockEntityComponents`
/// for the block entity just placed at `pos` from `stack`; `game_master`:
/// the placer can use game master blocks (a dispenser can't).
+23 -4
View File
@@ -76,9 +76,11 @@ fn view_pages(data: Option<&StackData>) -> Vec<Text> {
.iter()
.enumerate()
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
horizon_client::entity_nbt::name_text(tag)
}
// A component object, or a component list (`ComponentSerialization`
// takes both; servers behind ViaVersion send the list form).
Some(
tag @ (horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::List(_)),
) => horizon_client::entity_nbt::name_text(tag),
_ => Text::literal(page),
})
.collect()
@@ -137,6 +139,18 @@ impl Game<'_> {
}
}
ClickEvent::OpenUrl(url) => self.click_url(url),
ClickEvent::RunCommand(command) => self.click_command(&command),
ClickEvent::SuggestCommand(command) => {
// defaultHandleClickEvent: the chat screen takes it over.
if self.player.is_some() {
self.close_book();
self.open_chat(&command);
}
}
ClickEvent::CopyToClipboard(value) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
_ => {}
}
}
@@ -1144,7 +1158,12 @@ impl Game<'_> {
BLACK,
);
}
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
// BookSignScreen: `owner.getName()`, the account's name on a server.
let owner = self
.remote
.as_ref()
.map_or(super::chat::PLAYER_NAME, |r| r.profile_name.as_str());
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", owner);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
atlas,
+246
View File
@@ -0,0 +1,246 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use horizon_client::{
particles::{ParticleOptions, ParticleType},
save,
};
use horizon_core::random::RandomSource;
use std::collections::HashMap;
/// `BrushItem.USE_DURATION`.
const USE_DURATION: u32 = 200;
/// A brushable block's counters. Vanilla never saves them: the block's
/// `dusted` state is all that is left after a reload.
#[derive(Default)]
pub(super) struct BrushState {
count: i32,
resets_at: i64,
cooldown_ends_at: i64,
hit_face: Option<[i32; 3]>,
}
/// `BrushableBlockEntity.getCompletionState`: the `dusted` property.
fn completion_state(count: i32) -> i32 {
match count {
0 => 0,
1..=2 => 1,
3..=5 => 2,
_ => 3,
}
}
pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
impl Game<'_> {
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
/// hand of `slot` acting.
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
// No block under the crosshair: releaseUsingItem.
let Some(hit) = self.pick() else {
self.using = None;
return;
};
let elapsed = ticks + 1;
// The last tick before the backswing.
if elapsed % 10 == 5 {
self.brush_stroke(&hit, slot);
}
// The use ends after 200 ticks; the held button starts it again.
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
}
/// One stroke on the block aimed at: dust (this window only), the brush
/// sound, and the block entity's count. The brush wears when the block
/// changed state.
fn brush_stroke(&mut self, hit: &Hit, slot: usize) {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let name = self.res.facts().get(state).name.clone();
let (sound, brushable) = match &*name {
"minecraft:suspicious_sand" => ("minecraft:item.brush.brushing.sand", true),
"minecraft:suspicious_gravel" => ("minecraft:item.brush.brushing.gravel", true),
_ => ("minecraft:item.brush.brushing.generic", false),
};
if self.lan_acting.is_none() {
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
}
let at = hit.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(sound, Some(at), 1.0, 1.0);
if brushable && self.brush_block(hit.pos, hit.face) {
self.damage_held(1);
}
}
/// `BrushItem.spawnDustParticles`.
pub(super) fn brush_dust(&mut self, hit: &Hit, off_hand: bool) {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let Some((sprite, tint)) = self.particle_sprite(state, hit.pos) else {
return;
};
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
// The arm that brushes: the main arm, or the other one for the off hand.
let flip = if off_hand == self.settings.main_hand_left {
1.0
} else {
-1.0
};
let view = view_vector(self.yaw, self.pitch);
// DustParticlesDelta.fromDirection.
let (dx, dz) = match hit.face {
[0, _, 0] => (view[2], -view[0]),
[0, 0, -1] => (1.0, -0.1),
[0, 0, 1] => (-1.0, 0.1),
[-1, 0, 0] => (-0.1, -1.0),
_ => (0.1, 1.0),
};
let count = 7 + self.particles.level_random().next_int_bound(5).unwrap_or(0);
let x = hit.point[0] - if hit.face == [-1, 0, 0] { 1.0e-6 } else { 0.0 };
let z = hit.point[2] - if hit.face == [0, 0, -1] { 1.0e-6 } else { 0.0 };
for _ in 0..count {
let random = self.particles.level_random();
let v = [
dx * flip * 3.0 * random.next_double(),
0.0,
dz * flip * 3.0 * random.next_double(),
];
self.particles.add(&options, [x, hit.point[1], z], v);
}
}
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
return false;
}
brush.cooldown_ends_at = time + 10;
self.unpack_brush_loot(pos);
let before = completion_state(brush.count);
brush.count += 1;
if brush.count >= 10 {
self.brushing_completed(pos, brush.hit_face);
return true;
}
self.schedule_brushable_tick(pos);
let after = completion_state(brush.count);
self.brushing.insert(pos, brush);
if before != after {
self.set_dusted(pos, after);
}
false
}
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
return;
};
if tag.get("LootTable").is_none() {
return;
}
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
&mut tag,
loot.as_deref(),
&mut self.world.random,
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
/// brushed face and the block turns into its sand or gravel.
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let turns_into = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:sand",
"minecraft:suspicious_gravel" => "minecraft:gravel",
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
item.count = item.count.min(take as u32);
let at = [
f64::from(pos[0] + face[0]) + 0.5,
f64::from(pos[1] + face[1]) + 0.625,
f64::from(pos[2] + face[2]) + 0.5,
];
self.spawn_item(item, at, [0.0; 3], 0);
}
// Level event 3008 for every player, then the block change.
self.level_event_at(3008, pos, state.0 as i32);
if let Some(base) = self.pack.assets.registry.default_state(turns_into) {
let _ = self.level_set(pos, base);
}
}
fn set_dusted(&mut self, pos: [i32; 3], dusted: i32) {
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.cloned()
else {
return;
};
state.properties.insert("dusted".into(), dusted.to_string());
if let Ok(state) = registry.resolve(&state) {
let _ = self.level_set(pos, state);
}
}
fn schedule_brushable_tick(&mut self, pos: [i32; 3]) {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.schedule_tick(level, ticks, at, 2));
}
/// `BrushableBlockEntity.checkReset`, from `BrushableBlock.tick`: a block
/// left alone loses two counts, again every four ticks.
pub(super) fn brushable_tick(&mut self, pos: [i32; 3]) {
let time = self.world.game_time;
let Some(mut brush) = self.brushing.remove(&pos) else {
return;
};
let before = completion_state(brush.count);
if brush.count != 0 && time >= brush.resets_at {
brush.count = (brush.count - 2).max(0);
brush.resets_at = time + 4;
}
let after = completion_state(brush.count);
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
}
if before != after {
self.set_dusted(pos, after);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
let state = horizon_world::registry::BlockStateId(data as u32);
let sound = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:item.brush.brushing.sand.complete",
"minecraft:suspicious_gravel" => "minecraft:item.brush.brushing.gravel.complete",
_ => "",
};
if !sound.is_empty() {
let at = pos.map(|v| f64::from(v) + 0.5);
// Each client plays it from the event itself.
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
}
self.destroy_particles(pos, state);
}
}
@@ -2,8 +2,60 @@
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
use horizon_core::position::BlockPos;
use horizon_world::chunk::BlockState;
use winit::keyboard::KeyCode;
impl Game<'_> {
/// The lit campfire at `pos` that `held` puts out, when the item is in
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
})?;
self.pack
.assets
.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()
}
/// `CampfireBlock.useItemOn` as this client runs it before the server's
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
/// under `seq`. The level event's sound comes from the server. True when
/// it doused (SUCCESS), which ends the click for both hands.
pub(super) fn predict_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
seq: i32,
) -> bool {
// Player.isSecondaryUseActive: a held item means both hands count.
if self.keys.contains(&KeyCode::ShiftLeft) {
return false;
}
let Some(mut state) = self.campfire_to_douse(pos, held) else {
return false;
};
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);
}
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.predict_blocks(&vec![(at, unlit)], seq);
}
true
}
/// `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(
@@ -11,24 +63,11 @@ impl Game<'_> {
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()
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
else {
return Ok(false);
};
let registry = self.pack.assets.registry;
// 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),
@@ -44,7 +83,6 @@ impl Game<'_> {
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);
+144 -35
View File
@@ -81,6 +81,8 @@ pub struct UiState {
pub chat_screen: Option<ChatScreen>,
/// When the chat screen opened (its input took the focus: the cursor blink).
pub chat_opened: Option<std::time::Instant>,
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
pub in_bed_chat: bool,
pub registries: Registries,
pub rules: GameRules,
pub clock: WorldClock,
@@ -102,6 +104,9 @@ pub struct UiState {
/// Death screen cause and when the player died (buttons wait 20 ticks).
pub death_text: Option<Text>,
pub died_at: Option<std::time::Instant>,
/// `DeathScreen.TitleConfirmScreen` and when it opened (its buttons wait
/// 20 ticks, `setDelay`).
pub death_confirm: Option<std::time::Instant>,
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
pub death_time: u32,
}
@@ -452,6 +457,12 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_chat(&mut self) {
// InBedChatScreen.onClose: leaving the screen means getting up; the
// screen goes once the player has woken (`tick_in_bed_chat`).
if self.ui.in_bed_chat && self.sleeping.is_some() {
self.wake_up();
return;
}
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
if self.screen == Screen::Chat {
@@ -459,6 +470,35 @@ impl Game<'_> {
self.capture(true);
}
}
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
/// screen if something was typed.
pub(super) fn tick_in_bed_chat(&mut self) {
if self.screen != Screen::Chat {
self.ui.in_bed_chat = false;
}
if self.ui.in_bed_chat {
if self.sleeping.is_none() {
self.ui.in_bed_chat = false;
let text = self
.ui
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat();
if !text.is_empty() {
self.open_chat(&text);
}
}
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
self.open_chat("");
self.ui.in_bed_chat = self.screen == Screen::Chat;
}
}
/// The Leave Bed button of `InBedChatScreen`: its bounds.
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
@@ -571,7 +611,15 @@ impl Game<'_> {
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => self.close_chat(),
ChatAction::Submit(message) => {
self.close_chat();
// InBedChatScreen sends without closing (`closeOnSubmit` is
// false): the box empties and the screen stays.
if self.ui.in_bed_chat && self.sleeping.is_some() {
screen.suggest_command(&m, &c, "");
self.ui.chat.reset_scroll();
self.ui.chat_screen = Some(screen);
} else {
self.close_chat();
}
if !message.is_empty() {
self.handle_chat_input(&message);
}
@@ -673,7 +721,8 @@ impl Game<'_> {
{
return;
}
let shift = self.keys.contains(&KeyCode::ShiftLeft);
// ChatScreen.mouseScrolled: `hasShiftDown`, either Shift key.
let shift = self.has_shift_down();
let amount = amount.clamp(-1.0, 1.0) * if shift { 1.0 } else { 7.0 };
self.ui.chat.scroll_by(amount as i32, true);
}
@@ -687,6 +736,16 @@ impl Game<'_> {
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if self.ui.in_bed_chat && self.sleeping.is_some() {
let width = self.renderer.as_ref().map_or(1280.0, |r| {
HudBatch::new([r.config.width, r.config.height]).width
});
let (x, y, w, h) = Self::leave_bed_button(width, height);
if horizon_client::screen_draw::inside((cursor.0 as i32, cursor.1 as i32), x, y, w, h) {
self.wake_up();
return;
}
}
if let Some(screen) = self.ui.chat_screen.as_mut() {
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
return;
@@ -694,12 +753,25 @@ impl Game<'_> {
} else {
return;
}
match self.ui.chat.click_at(
let Some(style) = self.ui.chat.style_at(
&self.gui.atlas.font,
height as i32,
cursor.0 as f64,
cursor.1 as f64,
) {
) else {
return;
};
// ChatScreen.handleComponentClicked: with Shift held only an
// insertion acts, else only the click event.
if self.has_shift_down() {
if let Some(insertion) = style.insertion
&& let Some(screen) = self.ui.chat_screen.as_mut()
{
screen.type_text(&m, &c, &insertion);
}
return;
}
match style.click {
Some(ClickEvent::CopyToClipboard(value)) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
@@ -713,6 +785,7 @@ impl Game<'_> {
screen.suggest_command(&m, &c, &command);
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
Some(ClickEvent::ChangePage(_)) | None => {}
}
@@ -781,6 +854,22 @@ impl Game<'_> {
self.answer_link_prompt(yes);
}
}
/// `Screen.clickCommandAction`: run a clicked command as the player
/// without adding it to the chat history. A client mod's command goes
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
/// commands take it. (Vanilla asks first when the command fails to
/// parse; here the server answers it.)
pub(super) fn click_command(&mut self, command: &str) {
let command = command.strip_prefix('/').unwrap_or(command);
if self.mod_command(command) {
return;
}
if self.remote.is_some() {
self.send_chat(&format!("/{command}"));
} else if !self.local_server_mod_command(command) {
self.run_command(command);
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
let focused = self.screen == Screen::Chat;
@@ -815,17 +904,59 @@ impl Game<'_> {
let draws = screen.draw(&m, mouse, blink, background);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
// The hovered message part's tooltip, one row per line of its text.
if let Some(hover) = self.ui.chat.hover_at(
&self.gui.atlas.font,
batch.height as i32,
f64::from(self.cursor.0),
f64::from(self.cursor.1),
) {
let lines = hover_rows(&hover);
if self.ui.in_bed_chat && self.sleeping.is_some() {
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
&m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
}
// The hovered message part's tooltip (`renderComponentHoverEffect`),
// or the text of the message tag under the cursor.
let height = batch.height as i32;
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
let wrap = (batch.width as i32 / 2).max(200);
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, wrap);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
} else if let Some(stack) = style
.hover_item
.as_deref()
.and_then(horizon_client::item_data::stack_from_hover)
.filter(|s| self.res.items().get(&s.item).is_some())
{
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
}
} else if self.ui.chat.indicator_at(height, cx, cy) {
// GuiMessageTag.systemSinglePlayer's text, split at 210.
let tag = Text::translate("chat.tag.system_single_player", &[]);
let lines = self.wrapped_rows(&tag, 210);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
let split = self
.gui
.atlas
.font
.split(&text.resolve_legacy(), width as f32);
split.into_iter().map(|row| (row, u32::MAX)).collect()
}
/// Advance the overworld clock (ServerClockManager.tick) when
/// `advance_time` allows it.
pub(super) fn advance_clock(&mut self) {
@@ -970,6 +1101,7 @@ impl Game<'_> {
let cause = self.ui.death_cause.take();
self.ui.died_at = Some(std::time::Instant::now());
self.ui.death_text = None;
self.ui.death_confirm = None;
if !self.ui.rules.bool("show_death_messages") {
return;
}
@@ -1078,26 +1210,3 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
fn owner_permission(allow_commands: bool) -> u8 {
if allow_commands { 4 } else { 0 }
}
/// A hover text's rows for the tooltip: split at line breaks, each in the
/// colour of its first styled character (white without one).
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
let mut rows = vec![(String::new(), None)];
for (c, style) in hover.resolve_legacy().chars() {
if c == '\n' {
rows.push((String::new(), None));
continue;
}
let row = rows.last_mut().expect("one row");
if row.0.is_empty() {
row.1 = style.color;
}
row.0.push(c);
}
rows.into_iter()
.map(|(text, color)| {
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
(text, horizon_client::hud::rgba(r, g, b, 255))
})
.collect()
}
+4
View File
@@ -42,6 +42,10 @@ pub(super) struct CombatTracker {
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
pub(super) player_attacker: Option<([f64; 3], f32)>,
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
+147 -78
View File
@@ -1162,6 +1162,17 @@ impl Game<'_> {
// An air stack is empty: nothing is given (but the message still is).
let empty = id == "minecraft:air";
for &t in targets.iter().filter(|_| !empty) {
// The player's UUID, which an item they could not hold is set to.
let conn = match t {
Target::Guest(conn) => Some(conn),
_ => self.lan_acting,
};
let uuid = match conn {
Some(conn) => self
.command_guest(conn)
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid)),
None => Some(self.level.ensure_player_uuid()),
};
self.for_player(t, |g| {
let mut remaining = count;
while remaining > 0 {
@@ -1178,8 +1189,19 @@ impl Game<'_> {
.target_position(Target::Player)
.unwrap_or(g.source_position());
if let Some(rest) = left {
// Inventory full: drop the rest at the player's feet without pickup delay.
g.spawn_item(rest, [p[0], p[1] + 1.32, p[2]], [0.0, 0.0, 0.0], 0);
// Inventory full: Player.createItemStackToDrop (thrown from
// the eyes), then setNoPickUpDelay and setTarget(player).
let eye = g.eye_position().unwrap_or([
p[0],
p[1] + g.target_eye_height(Target::Player),
p[2],
]);
g.throw_from_eyes(rest, eye, false);
if let Some(item) = g.dropped.last_mut() {
item.thrower = None;
item.pickup_delay = 0;
item.target = uuid;
}
} else {
let r = &mut g.world.random;
let pitch = ((r.next_float() - r.next_float()) * 0.7 + 1.0) * 2.0;
@@ -1447,7 +1469,7 @@ impl Game<'_> {
{
return Err(err("commands.teleport.invalidPosition", &[]));
}
let mut rotation = match ctx.get("rotation") {
let rotation = match ctx.get("rotation") {
Some(Arg::Coords(c)) => Some(c.rotation(self.source_rotation())),
_ => None,
};
@@ -1462,21 +1484,31 @@ impl Game<'_> {
(Some(f), _) => Some(f),
(None, Some(_)) => {
let e = self.entities(ctx, "facingEntity", false)?[0];
// EntityAnchorArgument.Anchor.apply: the feet, or the eyes.
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
let eye_height = if eyes { self.target_eye_height(e) } else { 0.0 };
self.target_position(e)
.map(|p| [p[0], p[1] + if eyes { 1.62 } else { 0.0 }, p[2]])
.map(|p| [p[0], p[1] + eye_height, p[2]])
}
_ => None,
};
if let Some(f) = facing {
let eye = pos[1] + 1.62;
// Entity.lookAt: from the source's anchor (the feet, unless `anchored
// eyes`) on the teleported entity, so the eyes depend on the target.
let anchored_eyes = self.command_source.is_some_and(|s| s.eyes);
let look_at = |this: &Self, t: Target| {
let f = facing?;
let eye = if anchored_eyes {
pos[1] + this.target_eye_height(t)
} else {
pos[1]
};
let (dx, dy, dz) = (f[0] - pos[0], f[1] - eye, f[2] - pos[2]);
let horizontal = (dx * dx + dz * dz).sqrt();
rotation = Some([
Some([
((dz.atan2(dx).to_degrees()) - 90.0) as f32,
(-(dy.atan2(horizontal).to_degrees())) as f32,
]);
}
])
};
// A server's player in another level than the source's (`execute
// in`, the console's Overworld) moves there (ServerPlayer.teleport
// into another level), after the command.
@@ -1486,6 +1518,7 @@ impl Game<'_> {
None => self.dim,
};
for &t in &targets {
let rotation = look_at(self, t).or(rotation);
if self.teleport_across(t, source_level, pos, rotation) {
continue;
}
@@ -2210,7 +2243,8 @@ impl Game<'_> {
let dmax = [dest[0] + size[0], dest[1] + size[1], dest[2] + size[2]];
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
let moving = ctx.has("move");
if !ctx.has("force") && overlap {
// Mode.canOverlap: only the normal mode refuses overlapping regions.
if !ctx.has("force") && !moving && overlap {
return Err(err("commands.clone.overlap", &[]));
}
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
@@ -2419,26 +2453,54 @@ impl Game<'_> {
let levels = ctx.has("levels");
let unit = if levels { "levels" } else { "points" };
if ctx.has("query") {
let _ = self.entities(ctx, "target", true)?;
let e = &self.experience;
let value = if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32) as i32
};
let target = self.entities(ctx, "target", true)?[0];
// ExperienceCommand.queryExperience: the target's own level or points.
let value = self
.for_player(target, |g| {
let e = &g.experience;
if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32)
as i32
}
})
.unwrap_or(0);
self.system_message(Text::translate(
&format!("commands.experience.query.{unit}"),
&[
Text::literal(self.acting_player_name()),
Text::literal(value.to_string()),
],
&[self.target_name(target), Text::literal(value.to_string())],
));
return Ok(value);
}
let targets = self.entities(ctx, "target", true)?;
let amount = Self::int(ctx, "amount").unwrap_or(0);
let op = if ctx.has("add") { "add" } else { "set" };
if op == "add" {
let add = ctx.has("add");
let op = if add { "add" } else { "set" };
// Every target changes (a set of points can be refused for some).
let changed: Vec<Target> = targets
.iter()
.copied()
.filter(|&t| {
self.for_player(t, |g| g.change_experience(add, levels, amount)) == Some(true)
})
.collect();
if changed.is_empty() {
return Err(err("commands.experience.set.points.invalid", &[]));
}
let (key, who) = match changed[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(changed.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.experience.{op}.{unit}.success.{key}"),
&[Text::literal(amount.to_string()), who],
));
Ok(changed.len() as i32)
}
/// ExperienceCommand's add and set on the player in place: false when
/// the points to set do not fit the level.
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
if add {
if levels {
self.experience.level = (self.experience.level + amount).max(0);
if self.experience.level == 0 && amount < 0 {
@@ -2452,26 +2514,11 @@ impl Game<'_> {
} else {
let needed = horizon_client::entities::Experience::needed(self.experience.level);
if amount > needed - 1 {
return Err(err("commands.experience.set.points.invalid", &[]));
return false;
}
self.experience.progress = amount as f32 / needed as f32;
}
let key = format!(
"commands.experience.{op}.{unit}.success.{}",
if targets.len() == 1 {
"single"
} else {
"multiple"
}
);
self.feedback(Text::translate(
&key,
&[
Text::literal(amount.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(targets.len() as i32)
true
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
@@ -2556,21 +2603,26 @@ impl Game<'_> {
};
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
let pitch = rot[1].clamp(-90.0, 90.0);
// SetSpawnCommand: a forced point in the source's level.
if self.dim == Dim::Overworld {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
} else {
self.spawn = None;
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos,
yaw,
forced: true,
// SetSpawnCommand: a forced point in the source's level, for each target.
let level = self.dim;
for &t in &targets {
self.for_player(t, |g| {
if level == Dim::Overworld {
g.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
g.respawn_config = None;
} else {
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
}
});
}
let dim = Text::literal(self.dim.id());
@@ -2750,6 +2802,44 @@ impl Game<'_> {
},
};
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
let counting = max == 0;
// ClearInventoryCommands.clearInventory: each player on their own.
let mut removed = 0i32;
let mut found = Vec::new();
for &t in &targets {
let n = self
.for_player(t, |g| g.clear_matching(&predicate, max))
.unwrap_or(0);
removed += n;
if n != 0 {
found.push(t);
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[self.target_name(targets[0])],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
let (key, who) = match found[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(found.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.clear.{kind}.{key}"),
&[Text::literal(removed.to_string()), who],
));
Ok(removed)
}
/// Inventory.clearOrCountMatchingItems for the player in place: `max`
/// of the matching items go (all when negative, none and only counted
/// when 0). Returns how many matched.
fn clear_matching(&mut self, predicate: &ItemInput, max: i32) -> i32 {
let counting = max == 0;
// Inventory slots 0..40: main, armor feet to head, off hand;
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
@@ -2765,10 +2855,7 @@ impl Game<'_> {
};
let matching: Vec<bool> = all(self)
.iter()
.map(|s| {
s.as_ref()
.is_some_and(|s| self.stack_matches(&predicate, s))
})
.map(|s| s.as_ref().is_some_and(|s| self.stack_matches(predicate, s)))
.collect();
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
slots.extend(self.inventory.iter_mut());
@@ -2798,25 +2885,7 @@ impl Game<'_> {
}
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[Text::literal(self.acting_player_name())],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
self.feedback(Text::translate(
&format!("commands.clear.{kind}.single"),
&[
Text::literal(removed.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(removed)
removed
}
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
@@ -227,7 +227,7 @@ impl Game<'_> {
}
/// Whether the level in place has a running task, which the server pumps
/// between its ticks (`pump_pregen`).
/// between its ticks (`pump_chunks`).
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
self.cmds
.pregen
@@ -243,11 +243,19 @@ impl Game<'_> {
/// Keep the workers fed until `until`: collect what they finished and
/// 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) {
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
// And the players' chunks while they load, as vanilla's server runs
// its chunk tasks while it waits for the next tick
// (`MinecraftServer.waitUntilNextTick`): once a tick, a few jobs per
// worker (`schedule_generation`) capped joining a world at about 120
// columns a second with two workers, the workers idle most of the
// time. Not while a respawn search waits for a chunk: its area is
// only asked for by the tick's own `update_world`.
let players = !paused && self.spawn_wait.is_empty();
while self.pregen_runs_here()
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
{
let now = Instant::now();
if now >= until {
@@ -105,6 +105,7 @@ impl Game<'_> {
match ty.trim_start_matches("minecraft:") {
"player_attack" => DamageKind::PlayerAttack,
"mace_smash" => DamageKind::MaceSmash,
"spear" => DamageKind::Spear,
"mob_attack" | "mob_attack_no_aggro" => DamageKind::MobAttack,
"arrow" => DamageKind::Arrow,
"thrown" | "trident" | "mob_projectile" => DamageKind::Thrown,
@@ -118,7 +119,8 @@ impl Game<'_> {
"dry_out" => DamageKind::DryOut,
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"cactus" => DamageKind::Cactus,
"sweet_berry_bush" => DamageKind::SweetBerryBush,
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
@@ -1219,7 +1221,7 @@ impl Game<'_> {
/// LivingEntity.stopRiding for a mob: off its vehicle
/// (Entity.removePassenger), then to where the vehicle lets it out
/// (LivingEntity.dismountVehicle).
fn dismount_mob(&mut self, rider: u32) {
pub(in crate::game) fn dismount_mob(&mut self, rider: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
return;
};
@@ -204,6 +204,64 @@ impl Game<'_> {
}
}
// ---- interactions -----------------------------------------------------
/// `WeatheringCopperGolemStatueBlock.useItemOn`: an axe on an unoxidized
/// statue turns it back into a golem (`CopperGolemStatueBlockEntity.removeStatue`),
/// wearing the axe. Weathered statues are scraped by the axe's own
/// transform instead.
pub(super) fn use_golem_statue(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
if !held.is_some_and(|s| s.item.ends_with("_axe")) {
return false;
}
let registry = self.pack.assets.registry;
let Some(state) = registry.state(self.world.block(pos[0], pos[1], pos[2])) else {
return false;
};
if state.name != "minecraft:copper_golem_statue" {
return false;
}
// `Direction.toYRot` of the statue's facing.
let yaw = match state.properties.get("facing").map(String::as_str) {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
};
let waterlogged = state
.properties
.get("waterlogged")
.is_some_and(|w| w == "true");
// initCopperGolem: at the block's feet, facing as the statue does.
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let Some(id) = self.spawn_mob("minecraft:copper_golem", at, true) else {
return false;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.yaw = yaw;
m.previous_yaw = yaw;
m.body_yaw = yaw;
m.previous_body_yaw = yaw;
m.head_yaw = yaw;
m.previous_head_yaw = yaw;
}
self.damage_held(1);
self.audio
.play("minecraft:entity.copper_golem.spawn", Some(at), 1.0, 1.0);
// Level.removeBlock(pos, false): air, or the water the statue stood in.
let replacement = registry.default_state(if waterlogged {
"minecraft:water"
} else {
"minecraft:air"
});
if let Some(replacement) = replacement {
let _ = self.level_set(pos, replacement);
}
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
true
}
/// `CopperGolem.mobInteract`; true when handled.
pub(super) fn interact_copper_golem(&mut self, index: usize, item: &str) -> bool {
if self.mobs[index].species() != Species::CopperGolem || !self.mobs[index].alive() {
@@ -0,0 +1,39 @@
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
use super::Game;
use horizon_client::{hotbar, keybinds, text::Text};
impl Game<'_> {
/// Whether the key bound to `mapping` is held.
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
keybinds::key_code(self.settings.keys.get(mapping))
.is_some_and(|k| self.physical_keys.contains(&k))
}
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
/// `index`: load puts the saved row in the hotbar (the creative slot
/// packets follow from the inventory change), save stores the hotbar and
/// says so on the action bar.
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
let path = Self::options_path().with_file_name("hotbar.nbt");
let known = |id: &str| self.res.items().get(id).is_some();
let mut rows = hotbar::load(&path, &known);
if load {
for (slot, stack) in rows[index].iter().enumerate() {
self.inventory[slot] = stack.clone();
}
return;
}
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
let _ = hotbar::save(&path, &rows);
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
let message = Text::translate(
"inventory.hotbarSaved",
&[
key("key.loadToolbarActivator"),
key(&format!("key.hotbar.{}", index + 1)),
],
);
self.titles.set_overlay(message, false);
}
}
+476 -65
View File
@@ -21,7 +21,7 @@ use horizon_client::{
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3, traverse_blocks},
geometry::{Aabb, Direction, Vec3, traverse_blocks},
position::BlockPos,
random::RandomSource,
};
@@ -46,9 +46,16 @@ pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// The players a fishing hook can catch, taken as the hooks tick, and
/// the pulls on them waiting for the hub (game/fishing.rs).
pub hook_targets: Vec<super::fishing::HookTarget>,
pub hook_pulls: Vec<(u32, horizon_core::geometry::Vec3)>,
/// Mace smash knockback on the other players from a guest's turn, waiting
/// for it to end (game/mace.rs).
pub smash_pushes: Vec<super::mace::SmashPush>,
/// Sweep attacks from a guest's turn, waiting for it to end
/// (game/lan_pvp.rs).
pub sweeps: Vec<super::lan_pvp::Sweep>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
@@ -56,6 +63,8 @@ pub(super) struct Creatures {
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
pub shoulder_time: std::collections::HashMap<[i32; 4], i64>,
/// The cat spawner's countdown (`CatSpawner.nextTick`).
pub cat_tick: i32,
/// Particles from a guest's turn, waiting for it to end
@@ -201,6 +210,7 @@ impl Game<'_> {
// EntitySelector.NO_CREATIVE_OR_SPECTATOR.
creative: self.creative || self.spectator(),
sneaking: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
swimming: p.swimming,
look: view_vector(self.yaw, self.pitch),
main_hand: self.held().map(|s| s.item.clone()),
off_hand: None,
@@ -220,6 +230,8 @@ impl Game<'_> {
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
sleeping: self.sleeping.is_some(),
sleep_timer: self.sleep_timer,
passenger: self.riding.is_some(),
})
}
@@ -450,9 +462,10 @@ impl Game<'_> {
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.res.items()),
lid_openers: &self.lid_openers,
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
self.player
.as_ref()
self.level_players()
.first()
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]),
),
}
@@ -507,6 +520,7 @@ impl Game<'_> {
self.tick_wandering_trader();
self.tick_patrol_spawner();
self.tick_cat_spawner();
self.tick_village_siege();
}
self.tick_raids();
self.tick_creature_entities()
@@ -772,11 +786,12 @@ impl Game<'_> {
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
let mut dying = m.clone();
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
// takes the experience (before the loot is dropped).
// takes the experience (before the loot is dropped). It takes what
// would drop, whoever killed the mob.
self.sculk_mob_died(m);
let consumed =
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
if by_player {
if horizon_client::mobs::drops::drops_experience(&dying) {
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
} else {
0
@@ -936,6 +951,55 @@ impl Game<'_> {
}
}
}
/// Melee or ranged damage to another mob; whether the hit landed.
fn mob_hits_mob(&mut self, target: u32, amount: f32, source: Damage) -> bool {
let mut killed = false;
let mut hurt = false;
let mut infested = false;
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
hurt = hit;
if hit {
killed = !t.alive();
// The hurt or death sound the mob raised.
let center = t.center();
for event in std::mem::take(&mut t.outbox) {
match event {
MobEvent::Sound(s, _, volume, pitch) => {
self.audio
.play(&format!("minecraft:{s}"), Some(center), volume, pitch);
}
MobEvent::Infested { .. } => infested = true,
other => {
// Left for the end of the tick.
t.outbox.push(other);
}
}
}
}
}
if infested {
self.mob_infested(target);
}
if hurt
&& let Some(Who::Mob(attacker)) = source.attacker
&& let Some(m) = self
.mobs
.iter()
.find(|m| m.id == attacker && m.species() == Species::Axolotl)
{
self.audio.play(
"minecraft:entity.axolotl.attack",
Some([m.position.x, m.position.y, m.position.z]),
1.0,
1.0,
);
}
if killed && let Some(Who::Mob(killer)) = source.attacker {
self.zombie_killed(killer, target);
}
hurt
}
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
pub(super) fn mob_infested(&mut self, mob: u32) {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
@@ -1015,6 +1079,20 @@ impl Game<'_> {
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
MobEvent::LevelParticles {
kind,
at,
spread,
speed,
count,
} => {
if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) {
let options = horizon_client::particles::ParticleOptions::simple(ty);
self.particles
.send_particles(&options, at, spread, speed, count);
self.lan_level_particles(&options, at, spread, speed, count);
}
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -1043,6 +1121,18 @@ impl Game<'_> {
MobEvent::AttackPlayer(amount, from) => {
self.mob_hits_player(amount, from, "minecraft:mob_attack");
}
// doHurtTarget: the effect only when hurtServer took damage.
MobEvent::AttackPlayerWithEffect {
amount,
from,
effect,
ticks,
amplifier,
} => {
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
self.give_player_effect(effect, ticks, amplifier);
}
}
MobEvent::DamagePlayer { amount, from, kind } => {
self.mob_hits_player(amount, from, kind);
}
@@ -1141,54 +1231,48 @@ impl Game<'_> {
amount,
source,
} => {
let mut killed = false;
let mut hurt = false;
let mut infested = false;
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
hurt = hit;
if hit {
killed = !t.alive();
// The hurt or death sound the mob raised.
let center = t.center();
for event in std::mem::take(&mut t.outbox) {
match event {
MobEvent::Sound(s, _, volume, pitch) => {
self.audio.play(
&format!("minecraft:{s}"),
Some(center),
volume,
pitch,
);
}
MobEvent::Infested { .. } => infested = true,
other => {
// Left for the end of the tick.
t.outbox.push(other);
}
}
}
self.mob_hits_mob(target, amount, source);
}
MobEvent::SonicBoom {
target,
attacker,
from,
direction,
} => {
// SonicBoom: the push only follows a hit that landed, scaled
// by the target's KNOCKBACK_RESISTANCE.
let (hurt, resistance) = match target {
Who::Player => {
let hurt = self.mob_hits_player(10.0, from, "minecraft:sonic_boom");
let resistance = self.player.as_mut().map_or(0.0, |p| {
p.attribute("minecraft:knockback_resistance").unwrap_or(0.0)
});
(hurt, resistance)
}
}
if infested {
self.mob_infested(target);
}
if hurt
&& let Some(Who::Mob(attacker)) = source.attacker
&& let Some(m) = self
.mobs
.iter()
.find(|m| m.id == attacker && m.species() == Species::Axolotl)
{
self.audio.play(
"minecraft:entity.axolotl.attack",
Some([m.position.x, m.position.y, m.position.z]),
1.0,
1.0,
);
}
if killed && let Some(Who::Mob(killer)) = source.attacker {
self.zombie_killed(killer, target);
Who::Mob(id) => {
let source = Damage {
kind: DamageKind::SonicBoom,
attacker: Some(Who::Mob(attacker)),
from: Some(from),
};
let hurt = self.mob_hits_mob(id, 10.0, source);
let resistance = self
.mobs
.iter()
.find(|m| m.id == id)
.map_or(0.0, |m| self.mob_knockback_resistance(m));
(hurt, resistance)
}
};
if hurt {
let horizontal = 2.5 * (1.0 - resistance);
let vertical = 0.5 * (1.0 - resistance);
let velocity = [
direction[0] * horizontal,
direction[1] * vertical,
direction[2] * horizontal,
];
self.handle_mob_events(vec![MobEvent::Push { target, velocity }])?;
}
}
MobEvent::FinishAdmiring { mob } => self.finish_admiring(mob),
@@ -1218,6 +1302,7 @@ impl Game<'_> {
}
MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob),
MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive),
MobEvent::ParrotToShoulder { mob } => self.parrot_to_shoulder(mob),
MobEvent::Infested { mob } => self.mob_infested(mob),
MobEvent::Gift { target, item } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target)
@@ -1236,6 +1321,14 @@ impl Game<'_> {
m.ai.target = Some(target);
}
}
MobEvent::HuntHoglin { mob, target, ticks } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.extra.ints[0] = ticks;
if m.ai.target.is_none() {
m.ai.target = Some(Who::Mob(target));
}
}
}
MobEvent::Projectile(mut p) => {
self.next_entity += 1;
p.id = self.next_entity;
@@ -1269,6 +1362,22 @@ impl Game<'_> {
[0.0, 0.2, 0.0],
10,
),
MobEvent::DropGift { table, at } => {
let stacks =
self.res
.items()
.loot_table(table, false, None, &mut self.world.random);
for stack in stacks {
// The ItemEntity constructor's random motion.
let r = &mut self.world.random;
let v = [
r.next_double() * 0.2 - 0.1,
0.2,
r.next_double() * 0.2 - 0.1,
];
self.spawn_item(stack, at, v, 10);
}
}
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
@@ -1331,8 +1440,50 @@ impl Game<'_> {
self.world.set(pos[0], pos[1], pos[2], state)?;
}
}
MobEvent::Infest {
pos,
infested,
poof,
} => {
let registry = &self.pack.assets.registry;
let old = self.world.block(pos[0], pos[1], pos[2]);
if let Some(mut state) = registry.state(old).cloned() {
let (from, to) = if infested {
("minecraft:", "minecraft:infested_")
} else {
("minecraft:infested_", "minecraft:")
};
state.name = state.name.replacen(from, to, 1);
if let Ok(id) = registry.resolve(&state) {
self.world.set(pos[0], pos[1], pos[2], id)?;
}
}
if let Some(mob) = poof {
self.mob_poof(mob);
}
}
MobEvent::BreakBlock { pos, drop } => {
let old = self.world.block(pos[0], pos[1], pos[2]);
// InfestedBlock.spawnAfterBreak: its silverfish comes out
// when blocks drop.
if drop
&& self
.res
.facts()
.get(old)
.name
.starts_with("minecraft:infested_")
&& self.ui.rules.bool("block_drops")
{
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
}
// Block.spawnDestroyParticles: fire breaks leave nothing.
if !self.res.facts().get(old).has_class("BaseFireBlock") {
self.destroy_particles(pos, old);
@@ -1420,6 +1571,27 @@ impl Game<'_> {
.list
.retain(|e| e.id != "mining_fatigue");
}
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
let Some(at) = self.mobs.iter().find(|m| m.id == mob).map(|m| {
[
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
]
}) else {
continue;
};
let names = self.structure_tag("minecraft:dolphin_located");
let found = self
.end
.locator()
.filter(|_| !names.is_empty())
.and_then(|t| t.locate(&names, at, 50));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.extra.home = found.map(|(_, p)| p);
}
}
MobEvent::EffectPlayer {
effect,
ticks,
@@ -2344,6 +2516,22 @@ impl Game<'_> {
8.0,
)
}
/// `BellBlock.onProjectileHit`: a projectile that strikes the bell proper
/// rings it, and a player's shot counts as the player's ring.
fn bell_projectile_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) {
let Some(face) = Direction::ALL.into_iter().find(|d| d.normal() == face) else {
return;
};
let block = BlockPos::new(pos[0], pos[1], pos[2]);
let rang = self
.with_level(|b, level, ticks| b.bell_projectile_hit(level, ticks, block, face, at.y));
if rang {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
self.stats.custom("bell_ring", 1);
}
}
}
/// 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};
@@ -2478,14 +2666,36 @@ impl Game<'_> {
let hit = self.with_level(|b, level, ticks| {
b.target_projectile_hit(level, ticks, block, location, face, arrow)
});
if hit.is_some() {
if let Some(strength) = hit {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
self.stats.custom("target_hit", 1);
let kind = if p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
{
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let projectile = horizon_client::advancements::EntityView {
kind: kind.to_owned(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.trigger_target_hit(&projectile, strength);
}
}
}
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
// projectile (a wind charge out of the world's top has no face).
if let Hit::Block { pos, at, face } = hit
&& face != [0; 3]
{
self.bell_projectile_hit(p, pos, at, face);
}
self.combat.projectile_owner = match p.owner {
Some(Who::Mob(id)) => Some(id),
_ => None,
@@ -2660,6 +2870,17 @@ impl Game<'_> {
};
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
}
// AbstractArrow.onHitEntity: KILLED_BY_ARROW for a kill
// (piercing arrows are not modelled).
if hurt
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
&& !m.alive()
{
let victim = self.mob_entity(m);
let weapon =
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref());
self.trigger_killed_by_arrow(&[victim], weapon);
}
}
hurt
}
@@ -2963,7 +3184,7 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& m.damage(4.0, source)
{
m.extra.levitation = 200;
m.add_effect("minecraft:levitation", 200, 0);
// Shulker.hurtServer: a shulker hit by a bullet.
horizon_client::mobs::shulker::hit_by_bullet(m);
}
@@ -3127,6 +3348,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.interact_shear_mob(index, &item) {
self.swing_arm();
return Ok(true);
}
// AbstractHorse.mobInteract: feed, equip or ride.
if self.interact_mount(index, &item) {
self.swing_arm();
@@ -3151,18 +3376,37 @@ impl Game<'_> {
.item_tags
.get("minecraft:creeper_igniters")
.is_some_and(|t| t.contains(&*item));
let fish = self
.res
.items()
.item_tags
.get("minecraft:fishes")
.is_some_and(|t| t.contains(&*item));
let damageable = self
.res
.items()
.get(&item)
.is_some_and(|i| i.max_damage > 0);
let parrot_poisonous = self
.res
.items()
.item_tags
.get("minecraft:parrot_poisonous_food")
.is_some_and(|t| t.contains(&*item));
let player_at = self.eye_position();
let acting = self.acting_player_uuid();
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
let held_damage = held.as_ref().map_or(0, |h| h.damage as i32);
let held_extra = held.as_ref().and_then(|h| h.extra.clone());
let m = &mut self.mobs[index];
if !m.alive() {
return Ok(false);
}
let species = m.species();
let center = m.center();
// Wolf armor the owner takes off with shears or leaves a dropped stack of.
let mut armor_drop: Option<ItemStack> = None;
let mut consume = false;
let mut give: Option<&str> = None;
let mut sound: Option<&str> = None;
@@ -3247,6 +3491,23 @@ impl Game<'_> {
handled = false;
}
}
// Entity.interact: shears take a tamed wolf's armor off for its owner.
Species::Wolf
if item == "minecraft:shears"
&& m.extra.tame
&& m.extra.owner == Some(acting)
&& m.extra.head.is_some()
&& !sneaking =>
{
let mut stack = ItemStack::new("minecraft:wolf_armor", 1);
stack.damage = m.extra.body_damage.max(0) as u32;
stack.extra = m.extra.body_extra.take();
armor_drop = Some(stack);
m.extra.head = None;
m.extra.body_damage = 0;
eat = Some(("item.armor.unequip_wolf", 1.0, 1.0));
self.damage_held_tool();
}
// Wolf.mobInteract: heal, dye the collar or sit/stand when tame;
// a bone tames a wild one a third of the time.
Species::Wolf if m.extra.tame && is_food && m.health < m.attrs.max_health => {
@@ -3255,11 +3516,42 @@ impl Game<'_> {
.items()
.get(&item)
.and_then(|i| i.food.as_ref())
.map_or(2.0, |f| f.0 as f32);
// TamableAnimal.feed: playEatingSound, which wolves leave empty.
.map_or(2.0, |f| f.0 as f32 * 2.0);
// TamableAnimal.feed(.., 2.0F, 2.0F): heals twice the nutrition,
// then playEatingSound, which wolves leave empty.
m.health = (m.health + heal).min(m.attrs.max_health);
consume = true;
}
// Wolf.mobInteract: the owner puts wolf armor on a tamed adult.
Species::Wolf
if item == "minecraft:wolf_armor"
&& m.extra.tame
&& m.extra.owner == Some(acting)
&& m.extra.head.is_none()
&& !m.is_baby() =>
{
m.extra.head = Some(item.as_str().into());
m.extra.body_damage = held_damage;
m.extra.body_extra = held_extra;
consume = true;
eat = Some(("item.armor.equip_wolf", 1.0, 1.0));
}
// Wolf.mobInteract: an armadillo scute repairs a sitting wolf's
// armor by an eighth of its durability.
Species::Wolf
if item == "minecraft:armadillo_scute"
&& m.extra.tame
&& m.extra.owner == Some(acting)
&& m.extra.sitting
&& m.extra.head.is_some()
&& m.extra.body_damage > 0 =>
{
m.extra.body_damage = (m.extra.body_damage
- (horizon_client::mobs::WOLF_ARMOR_DURABILITY as f32 * 0.125) as i32)
.max(0);
consume = true;
eat = Some(("item.wolf_armor.repair", 1.0, 1.0));
}
Species::Wolf
if m.extra.tame
&& m.extra.owner == Some(acting)
@@ -3379,6 +3671,20 @@ impl Game<'_> {
m.position.z.floor() as i32 >> 4,
));
}
// Parrot.mobInteract: the owner makes a tame parrot on the ground
// sit or stand (poisonous food is handled apart).
Species::Parrot
if m.extra.tame
&& m.extra.owner == Some(acting)
&& m.on_ground
&& !parrot_poisonous =>
{
m.extra.sitting = !m.extra.sitting;
self.world.modified.insert((
m.position.x.floor() as i32 >> 4,
m.position.z.floor() as i32 >> 4,
));
}
Species::Cat if !m.extra.tame && is_food => {
consume = true;
m.persistent = true;
@@ -3425,6 +3731,29 @@ impl Game<'_> {
consume = true;
}
}
// Parrot.mobInteract: #parrot_poisonous_food (a cookie) poisons the
// parrot for 45 s and then hurts it with a player attack of
// Float.MAX_VALUE, which an invulnerable one shrugs off.
Species::Parrot
if self
.res
.items()
.item_tags
.get("minecraft:parrot_poisonous_food")
.is_some_and(|t| t.contains(&*item)) =>
{
consume = true;
m.add_effect("minecraft:poison", 900, 0);
let id = m.id;
let hurt = m.damage(
f32::MAX,
horizon_client::mobs::Damage::player(player_at.unwrap_or(center)),
);
// The feeder gets the kill, as for a normal player attack.
if hurt {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
}
// Parrot.mobInteract: parrot food may tame a wild one (one time in
// ten); a parrot never takes it as Animal food (isFood is false).
Species::Parrot if !m.extra.tame && is_food => {
@@ -3436,7 +3765,7 @@ impl Game<'_> {
let tamed = m.random.next_int_bound(10).unwrap_or(1) == 0;
if tamed {
m.extra.tame = true;
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
m.extra.owner = Some(acting);
}
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
@@ -3445,6 +3774,18 @@ impl Game<'_> {
self.trigger_entity("minecraft:tame_animal", "entity", &view);
}
}
// Dolphin.mobInteract: a fish makes a baby grow by a tenth and an
// adult remember it (`gotFish`), to swim to a treasure.
Species::Dolphin if fish => {
eat = Some(("entity.dolphin.eat", 1.0, 1.0));
if m.extra.age < 0 {
let secs = ((-m.extra.age / 20) as f32 * 0.1) as i32;
horizon_client::mobs::ai::age_up(m, secs, true);
} else {
m.extra.flag = true;
}
consume = true;
}
// Tadpole.feed: frog food ages it by a tenth of the time left to
// grow into a frog (it grows on its next tick once past 24000).
Species::Tadpole if is_food && !m.extra.age_locked => {
@@ -3458,6 +3799,22 @@ impl Game<'_> {
horizon_client::particles::Burst::HORSE_EATING,
);
}
// Bee.mobInteract: a wither rose or an open eyeblossom is eaten for
// its `getBeeInteractionEffect` instead of breeding the bee.
Species::Bee
if is_food
&& matches!(
&*item,
"minecraft:wither_rose" | "minecraft:open_eyeblossom"
) =>
{
if &*item == "minecraft:wither_rose" {
m.add_effect("minecraft:wither", 40, 0);
} else {
m.add_effect("minecraft:poison", 25, 0);
}
consume = true;
}
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
_ if is_food
&& m.kind.flags.animal
@@ -3528,6 +3885,9 @@ impl Game<'_> {
self.spawn_item(rest, at, [0.0, 0.0, 0.0], 40);
}
}
if let Some(stack) = armor_drop {
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
for _ in 0..count {
let v = [
@@ -3550,6 +3910,39 @@ impl Game<'_> {
}
Ok(true)
}
/// `SnowGolem.mobInteract` and `Bogged.mobInteract` with shears: the
/// pumpkin or the mushrooms come off (the shearing loot table), the shear
/// game event is posted and the tool takes one damage.
fn interact_shear_mob(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
if item != "minecraft:shears" || !m.alive() || m.extra.sheared {
return false;
}
let (sound, table, height) = match m.species() {
Species::SnowGolem => (
"minecraft:entity.snow_golem.shear",
"minecraft:shearing/snow_golem",
m.eye_height(),
),
Species::Bogged => (
"minecraft:entity.bogged.shear",
"minecraft:shearing/bogged",
f64::from(m.height()),
),
_ => return false,
};
let at = m.position;
let tool = self.held().cloned();
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
self.mobs[index].extra.sheared = true;
for stack in self.sequence_loot(table, table, tool.as_ref()) {
self.spawn_at_location(at, height, stack);
}
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:shear", [at.x, at.y, at.z], ctx);
self.damage_held_tool();
true
}
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
/// (`brushOffScute`) and loses 16 durability.
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
@@ -3898,6 +4291,18 @@ impl Game<'_> {
&armor,
out,
);
if &*mob.kind.id == "minecraft:player" {
// PlayerRenderer's WingsLayer and SpinAttackEffectLayer.
self.player_elytra_geometry(
mob,
mob.extra.chest.as_deref(),
camera,
alpha as f32,
packed,
out,
);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
}
self.mob_block_geometry(mob, camera, alpha, packed, out);
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
@@ -4132,6 +4537,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
match kind {
K::PlayerAttack => "minecraft:player_attack",
K::MaceSmash => "minecraft:mace_smash",
K::Spear => "minecraft:spear",
K::MobAttack => "minecraft:mob_attack",
K::Arrow => "minecraft:arrow",
K::Thrown => "minecraft:thrown",
@@ -4148,6 +4554,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Magic => "minecraft:magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
K::SweetBerryBush => "minecraft:sweet_berry_bush",
K::Generic => "minecraft:generic",
K::InWall => "minecraft:in_wall",
K::Thorns => "minecraft:thorns",
@@ -4172,6 +4579,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
}
}
/// A generated loot stack as a Horizon item stack.
@@ -4191,6 +4599,9 @@ fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
if let Some(amplifier) = stack.ominous_amplifier.filter(|a| *a > 0) {
item.data_mut().ominous_amplifier = Some(amplifier as u8);
}
for (id, text) in &stack.names {
item.data_mut().set_loot_name(id, text);
}
item
}
impl Game<'_> {
@@ -4204,13 +4615,10 @@ impl Game<'_> {
f64::from((pos[0] >> 4) * 16 + 8),
f64::from((pos[2] >> 4) * 16 + 8),
];
let spectator = self.spectator();
let Some(close) = self.player.as_ref().filter(|_| !spectator).map(|p| {
let close = self.level_players().iter().any(|p| {
let (dx, dz) = (p.position.x - centre[0], p.position.z - centre[1]);
dx * dx + dz * dz < 16384.0
}) else {
return;
};
!p.spectator && dx * dx + dz * dz < 16384.0
});
if !close {
return;
}
@@ -4234,6 +4642,9 @@ impl Game<'_> {
f64::from(y + 1),
f64::from(pos[2]) + 0.5,
];
let _ = self.spawn_mob("minecraft:zombified_piglin", at, false);
// setPortalCooldown: the new piglin does not travel at once.
if let Some(id) = self.spawn_mob("minecraft:zombified_piglin", at, false) {
self.set_portal_cooldown(id);
}
}
}
@@ -219,6 +219,7 @@ impl Game<'_> {
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(ammo);
a.set_owner(Some(Who::Player));
a.fired_from_weapon = Some(weapon.clone());
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
@@ -235,6 +236,11 @@ impl Game<'_> {
.play("minecraft:item.crossbow.shoot", Some(eye), 1.0, pitch);
self.damage_held(if rocket { 3 } else { 1 });
}
// CrossbowItem.performShooting: SHOT_CROSSBOW with the crossbow, which
// is an empty stack (no match) when the shots broke it.
if let Some(stack) = self.held().cloned() {
self.trigger_item("minecraft:shot_crossbow", &stack);
}
}
}
+3 -2
View File
@@ -244,7 +244,8 @@ impl Game<'_> {
if on { "visible" } else { "hidden" }
)
};
let remote = self.remote.is_some();
// `DebugScreenOverlay`: the single-player variants for a local server.
let remote = !self.has_singleplayer_server();
let c = &self.debug_charts;
[
format!(
@@ -463,7 +464,7 @@ impl Game<'_> {
height,
self.settings.max_fps,
1000.0 / self.clock.rate(),
self.remote.is_some(),
!self.has_singleplayer_server(),
);
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
scale, scale,
+62 -1
View File
@@ -412,6 +412,8 @@ struct ServerState {
paused: bool,
/// `broadcast-console-to-ops`.
broadcast_console: bool,
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
op_level: i32,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -573,6 +575,14 @@ impl Game<'_> {
// DedicatedServer.initServer: worldData.setGameType; forceDifficulty.
self.set_default_game_mode(game_mode);
self.ui.difficulty = props.difficulty as u8;
// DedicatedServer.initServer: a legacy `announce-player-achievements`
// sets the show_advancement_messages rule once, as the file is read.
if let Some(announce) = props.announce_player_achievements {
self.ui.rules.set(
"show_advancement_messages",
horizon_client::gamerules::Value::Bool(announce),
);
}
self.settings.render_distance = props.view_distance.clamp(2, 32);
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
self.streaming.radius = self.settings.render_distance;
@@ -663,6 +673,9 @@ impl Game<'_> {
return Ok(());
}
self.sync_ops(&lists);
if let Some(host) = self.lan.as_mut() {
host.hub.spawn_protection = props.spawn_protection;
}
log(
"INFO",
&format!(
@@ -685,6 +698,7 @@ impl Game<'_> {
ticks: Default::default(),
paused: false,
broadcast_console: props.broadcast_console_to_ops,
op_level: props.op_permission_level,
};
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
@@ -946,7 +960,7 @@ impl Game<'_> {
let Some(user) = self.resolve(rest[0]) else {
return;
};
let level = 4;
let level = state.op_level;
let changed = {
let mut l = state.lists.lock().unwrap();
if command == "op" {
@@ -972,6 +986,10 @@ impl Game<'_> {
self.console_reply(Text::translate(key, &[lit(&user.name)]));
let lists = state.lists.clone();
self.sync_ops(&lists);
// DeOpCommands.deopPlayers.
if command == "deop" && changed {
self.kick_unlisted_players(&lists);
}
}
"whitelist" => self.console_whitelist(state, &rest),
"ban" if !rest.is_empty() => {
@@ -1183,6 +1201,7 @@ impl Game<'_> {
["on"] | ["off"] => {
let on = rest[0] == "on";
let mut l = state.lists.lock().unwrap();
let turned_on = on && !l.use_whitelist;
let key = match (on, l.use_whitelist) {
(true, true) => "commands.whitelist.alreadyOn",
(true, false) => "commands.whitelist.enabled",
@@ -1193,6 +1212,10 @@ impl Game<'_> {
drop(l);
self.store_property("white-list", on.to_string());
self.console_reply(Text::translate(key, &[]));
if turned_on {
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
}
["list"] => {
let names: Vec<String> = state
@@ -1216,6 +1239,8 @@ impl Game<'_> {
["reload"] => {
let _ = state.lists.lock().unwrap().white.load();
self.console_reply(Text::translate("commands.whitelist.reloaded", &[]));
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
["add", name] | ["remove", name] => {
let Some(user) = self.resolve(name) else {
@@ -1241,6 +1266,10 @@ impl Game<'_> {
Vec::new()
};
self.console_reply(Text::translate(key, &args));
if ok && rest[0] == "remove" {
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
}
_ => self.console_reply(Text::translate("command.unknown.argument", &[])),
}
@@ -1256,6 +1285,38 @@ impl Game<'_> {
}
}
/// MinecraftServer.kickUnlistedPlayers: with `enforce-whitelist` and the
/// whitelist on, whoever is online and not on the whitelist file is
/// disconnected (operators are not spared here).
fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
let unlisted: Vec<u128> = {
let l = lists.lock().unwrap();
let Some(host) = self.lan.as_ref() else {
return;
};
if !(l.enforce_whitelist && l.use_whitelist) {
return;
}
host.hub
.guests
.iter()
.filter(|g| {
!l.white.contains(&NameAndId {
id: g.uuid,
name: g.name.clone(),
})
})
.map(|g| g.uuid)
.collect()
};
for uuid in unlisted {
self.kick_uuid(
uuid,
translatable("multiplayer.disconnect.not_whitelisted", &[]),
);
}
}
fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
if let Some(h) = self.lan.as_mut() {
let conns: Vec<u32> = h
@@ -672,6 +672,7 @@ impl Game<'_> {
}
let source = match source.kind {
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
_ => "minecraft:player_attack",
};
out.push(format!(
@@ -1548,6 +1548,7 @@ impl Game<'_> {
return Ok((Some(stack), FAIL));
};
let target = (placed.x, placed.y, placed.z);
let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?;
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
self.world.block_entities.insert(target, entity);
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
+19 -16
View File
@@ -1122,14 +1122,11 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
let modifiers = self
.attack_item_info()
.map(|i| (i.attack_damage, i.attack_speed))
.unwrap_or((0.0, 0.0));
let speed = (4.0 + modifiers.1).max(0.1);
let scale = ((self.attack_ticks as f32 + 0.5) / (20.0 / speed)).clamp(0.0, 1.0);
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
let mut amount =
(1.0 + modifiers.0 + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
let falling = self
.player
.as_ref()
@@ -1465,7 +1462,16 @@ impl Game<'_> {
}
}
let d2 = (pp.x - pos.x).powi(2) + (pp.y - pos.y).powi(2) + (pp.z - pos.z).powi(2);
if d2 < 4096.0 {
// Entity.hasLineOfSight: eye to eye (the dragon's eye is 0.85 of
// its 8 block height), blocks only.
let sees = d2 < 4096.0 && {
let eye = Vec3::new(pos.x, pos.y + 6.8, pos.z);
let target_eye =
tracked.map_or(pp.y + 1.62, |p| p.bounds.max_position().y - 0.18);
self.mob_world_base(&[])
.line_of_sight(eye, Vec3::new(pp.x, target_eye, pp.z))
};
if sees {
*charge += 1;
let d = self.dragon.as_ref().expect("dragon");
let r = d.yaw.to_radians();
@@ -3020,14 +3026,11 @@ impl Game<'_> {
let (a, b) = (room.min_position(), room.max_position());
lo.x < b.x && hi.x > a.x && lo.y < b.y && hi.y > a.y && lo.z < b.z && hi.z > a.z
};
let player = self.player.as_ref().is_some_and(|p| {
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
touches(
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
Vec3::new(p.position.x + w, p.position.y + h, p.position.z + w),
)
});
// Level.getEntities: the host's player and every guest in the level.
let player = self
.fight_players()
.iter()
.any(|p| !p.spectator && touches(p.bounds.min_position(), p.bounds.max_position()));
player
|| self.crystals.iter().any(|c| {
touches(
@@ -3,14 +3,18 @@
//! 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, ...) and Soul
//! Speed's `location_changed` and `tick` effects.
//! the armor attribute effects (respiration, depth strider, ...), Soul
//! Speed's `location_changed` and `tick` effects and the
//! `minecraft:location_changed` disk effect (frost walker).
use super::Game;
use horizon_client::{
enchant::{EffectContext, Enchantments, level_value},
items::ItemStack,
mobs::Who,
};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
use serde_json::Value;
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
@@ -69,7 +73,7 @@ impl Game<'_> {
}
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
let Some(held) = self.inventory[self.selected].clone() else {
let Some(held) = self.held().cloned() else {
return 0.0;
};
let ctx = EffectContext {
@@ -102,7 +106,7 @@ impl Game<'_> {
/// whether the hit is that knockback attack.
pub(super) fn extra_knockback(&mut self, target_kind: &str, scale: f32) -> (f32, bool) {
let knockback_attack = scale > 0.9 && self.player.as_ref().is_some_and(|p| p.sprinting);
let enchanted = match self.inventory[self.selected].clone() {
let enchanted = match self.held().cloned() {
Some(held) => {
let ctx = EffectContext {
this: Some(target_kind),
@@ -144,7 +148,7 @@ impl Game<'_> {
}
/// After a successful melee hit: fire aspect and wind burst.
pub(super) fn post_attack(&mut self, mob: usize) {
let Some(held) = self.inventory[self.selected].clone() else {
let Some(held) = self.held().cloned() else {
return;
};
let Some(m) = self.mobs.get(mob) else { return };
@@ -211,6 +215,106 @@ impl Game<'_> {
}
self.post_attack_bursts(&held, &kind);
}
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
/// `minecraft:post_piercing_attack` effects of the weapon in hand after a
/// jab (Lunge's dash).
pub(super) fn post_piercing_attack(&mut self) {
let Some(held) = self.held().cloned() else {
return;
};
let mut effects = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
for entry in def.effects["minecraft:post_piercing_attack"]
.as_array()
.into_iter()
.flatten()
{
effects.push((*level, entry["effect"].clone()));
}
}
// Lunge's requirements: not riding, gliding or in water, and a
// player outside creative with at least 7 food.
let Some(p) = self.player.as_ref() else {
return;
};
if effects.is_empty()
|| self.riding.is_some()
|| p.fall_flying
|| p.fluids.was_touching_water
|| !(self.creative || p.health.food.food >= 7)
{
return;
}
for (level, effect) in effects {
self.apply_piercing_effect(&effect, level);
}
}
/// One `post_piercing_attack` effect on the player: `AllOf`,
/// `ChangeItemDamage`, `ApplyExhaustion`, `ApplyEntityImpulse` and
/// `PlaySoundEffect`.
fn apply_piercing_effect(&mut self, effect: &serde_json::Value, level: u32) {
let amount = || level_value(&effect["amount"], level);
match effect["type"].as_str() {
Some("minecraft:all_of") => {
for e in effect["effects"].as_array().into_iter().flatten() {
self.apply_piercing_effect(e, level);
}
}
Some("minecraft:change_item_damage") => self.damage_held(amount() as u32),
Some("minecraft:apply_exhaustion") => {
if let Some(p) = self.player.as_mut() {
p.cause_food_exhaustion(amount());
}
}
// The direction turned by the look (`Entity.getLookQuaternion`:
// pitch about x, then yaw about y), scaled by coordinate_scale
// and the magnitude, added to the velocity (the guest's client
// is told of it by `guest_hit_by`).
Some("minecraft:apply_impulse") => {
let vec3 = |key: &str| [0, 1, 2].map(|i| effect[key][i].as_f64().unwrap_or(0.0));
let (d, scale) = (vec3("direction"), vec3("coordinate_scale"));
let magnitude = f64::from(level_value(&effect["magnitude"], level));
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
f64::from(self.pitch).to_radians(),
);
let (y1, z1) = (
d[1] * pitch.cos() - d[2] * pitch.sin(),
d[1] * pitch.sin() + d[2] * pitch.cos(),
);
let impulse = horizon_core::geometry::Vec3::new(
(d[0] * yaw.cos() - z1 * yaw.sin()) * scale[0] * magnitude,
y1 * scale[1] * magnitude,
(d[0] * yaw.sin() + z1 * yaw.cos()) * scale[2] * magnitude,
);
if let Some(p) = self.player.as_mut() {
p.velocity = p.velocity.add_vector(impulse);
}
}
// The sound for the level (a list picks by level), heard by all.
Some("minecraft:play_sound") => {
let sounds: Vec<&str> = match &effect["sound"] {
serde_json::Value::Array(list) => {
list.iter().filter_map(|s| s.as_str()).collect()
}
one => one.as_str().into_iter().collect(),
};
let index = (level as usize).saturating_sub(1);
if let Some(sound) = sounds.get(index.min(sounds.len().saturating_sub(1))) {
let float = |key: &str| effect[key].as_f64().unwrap_or(1.0) as f32;
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio.play(sound, at, float("volume"), float("pitch"));
}
}
_ => {}
}
}
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
/// random duration and amplifier between the level-based bounds.
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
@@ -636,6 +740,173 @@ impl Game<'_> {
self.apply_equipment();
}
}
/// `EnchantmentHelper.runLocationChangedEffects` for the worn armor of the
/// player, run on the server once the player has moved: `LivingEntity.baseTick`
/// runs it when the block position changed, `checkFallDamage` when it lands.
/// `from` and `was_on_ground` are where and how the tick began.
pub(super) fn location_changed_effects(&mut self, from: [f64; 3], was_on_ground: bool) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let on_ground = p.ground.on_ground;
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
return;
}
let riding = self.riding.is_some();
let mut disks = Vec::new();
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
if !def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
{
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
for (effect, level) in disks {
self.replace_disk(&effect, level, at);
}
}
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
/// predicate become the effect's state, and each placement is a game event.
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
let registry = self.pack.assets.registry;
let spec = &effect["block_state"];
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
return;
};
let Some(mut state) = registry.state(default).cloned() else {
return;
};
for (k, v) in spec["properties"].as_object().into_iter().flatten() {
state
.properties
.insert(k.clone(), v.as_str().unwrap_or("").to_owned());
}
let new = registry.resolve(&state).unwrap_or(default);
let radius = level_value(&effect["radius"], level) as i32;
let height = level_value(&effect["height"], level) as i32;
let offset = cell_offset(effect);
let center = [0, 1, 2].map(|i| at[i].floor() as i32 + offset[i]);
let event = effect["trigger_game_event"].as_str() == Some("minecraft:block_place");
for dy in (height - 1).min(0)..=0 {
for dz in -radius..=radius {
for dx in -radius..=radius {
let pos = [center[0] + dx, center[1] + dy, center[2] + dz];
let (x, z) = (
f64::from(pos[0]) + 0.5 - at[0],
f64::from(pos[2]) + 0.5 - at[2],
);
if x * x + z * z >= f64::from(radius * radius)
|| !self.disk_predicate(&effect["predicate"], pos, new)
|| self.level_set(pos, new).is_err()
{
continue;
}
if event {
self.post_block_game_event(
"minecraft:block_place",
pos,
Some(Who::Player),
Some(new),
);
}
}
}
}
}
/// The `BlockPredicate` subset the disk effect uses, tested at `pos`; no
/// predicate matches everything.
fn disk_predicate(&mut self, p: &Value, pos: [i32; 3], placed: BlockStateId) -> bool {
if p.is_null() {
return true;
}
let offset = cell_offset(p);
let at = [0, 1, 2].map(|i| pos[i] + offset[i]);
let state = self.world.block(at[0], at[1], at[2]);
let b = self.res.behaviours();
let names = |v: &Value| -> Vec<String> {
match v {
Value::String(s) => vec![s.clone()],
Value::Array(a) => a
.iter()
.filter_map(|n| n.as_str().map(String::from))
.collect(),
_ => Vec::new(),
}
};
match p["type"].as_str().unwrap_or("") {
"minecraft:all_of" => p["predicates"]
.as_array()
.into_iter()
.flatten()
.all(|q| self.disk_predicate(q, pos, placed)),
// `MatchingBlockTagPredicate`
"minecraft:matching_block_tag" => b
.tag(p["tag"].as_str().unwrap_or("").trim_start_matches('#'))
.is_some_and(|t| t.contains(b.block(state))),
// `MatchingBlocksPredicate`
"minecraft:matching_blocks" => {
names(&p["blocks"]).iter().any(|n| n == b.table.name(state))
}
// `MatchingFluidsPredicate`: Fluids.WATER is the source.
"minecraft:matching_fluids" => {
let fluid = b.fluid(state);
names(&p["fluids"]).iter().any(|n| match n.as_str() {
"minecraft:water" => fluid.kind() == FluidKind::Water && fluid.is_source(),
"minecraft:flowing_water" => {
fluid.kind() == FluidKind::Water && !fluid.is_source()
}
_ => false,
})
}
// `UnobstructedPredicate`: no building-blocking entity in the block.
"minecraft:unobstructed" => self.with_level(|_, level, _| {
level.is_unobstructed(BlockPos::new(pos[0], pos[1], pos[2]), placed)
}),
_ => false,
}
}
/// Whether a worn enchantment gives immunity to `#burn_from_stepping`
/// damage (frost walker's `minecraft:damage_immunity`).
pub(crate) fn wears_burn_from_stepping_immunity(&self) -> bool {
self.armor
.iter()
.zip(["head", "chest", "legs", "feet"])
.any(|(stack, slot)| {
stack
.iter()
.flat_map(ItemStack::enchantments)
.any(|(id, _)| {
self.res.stations().enchantments.get(id).is_some_and(|def| {
def.effects.get("minecraft:damage_immunity").is_some()
&& def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
})
})
})
}
/// 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.
@@ -684,3 +955,35 @@ fn soul_speed_modifiers(
})
.collect()
}
/// An effect's or predicate's `offset` vector (zero when absent).
fn cell_offset(v: &Value) -> [i32; 3] {
let at = |i: usize| {
v["offset"]
.get(i)
.and_then(Value::as_i64)
.map_or(0, |n| n as i32)
};
[at(0), at(1), at(2)]
}
/// The loot-condition subset a `location_changed` effect requires of the
/// wearer: standing on the ground and not riding (frost walker).
fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
match req["type"].as_str().unwrap_or("") {
"" if req.is_null() => true,
"minecraft:all_of" => req["terms"]
.as_array()
.into_iter()
.flatten()
.all(|t| location_requirements(t, on_ground, riding)),
"minecraft:inverted" => !location_requirements(&req["term"], on_ground, riding),
"minecraft:entity_properties" => {
let p = &req["predicate"];
p["minecraft:flags"]["is_on_ground"]
.as_bool()
.is_none_or(|v| v == on_ground)
&& (p["minecraft:vehicle"].is_null() || riding)
}
_ => false,
}
}
+33 -8
View File
@@ -12,6 +12,7 @@ use horizon_client::{
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
vanillagen::VanillaTerrain,
world_spawn::PlayerSpawnFinder,
};
use horizon_core::{
geometry::Vec3,
@@ -581,7 +582,15 @@ impl Game<'_> {
let position = if let Some(job) = self.end.job.take() {
self.resolve_gateway(job, target)?
} else {
let (position, yaw) = self.end_arrival(target)?;
let mut finder = self.player_spawn.take();
let arrival = self.end_arrival(target, &mut finder);
self.player_spawn = finder;
let Some((position, yaw)) = arrival? else {
// A spawn candidate's chunk is loading: try again next tick.
self.travelling = Some((player, target));
self.end_travel = true;
return Ok(());
};
if let Some(yaw) = yaw {
self.yaw = yaw;
player.yaw = yaw;
@@ -606,7 +615,13 @@ impl Game<'_> {
/// Where an End portal's traveller lands once `target` has loaded, and
/// the yaw it faces there: the platform below the End's spawn point,
/// facing west, or the top of the Overworld's spawn column.
pub(super) fn end_arrival(&mut self, target: Vec3) -> Result<(Vec3, Option<f32>)> {
/// None while a candidate of the world spawn's search loads (`finder`
/// keeps it, see `search_spawn`).
pub(super) fn end_arrival(
&mut self,
target: Vec3,
finder: &mut Option<PlayerSpawnFinder>,
) -> Result<Option<(Vec3, Option<f32>)>> {
if self.dim == Dim::End {
// EndPlatformFeature.createEndPlatform below the spawn point.
let origin = [END_SPAWN[0], END_SPAWN[1] - 1, END_SPAWN[2]];
@@ -626,22 +641,32 @@ impl Game<'_> {
}
}
// Direction.WEST.toYRot().
return Ok((target, Some(90.0)));
return Ok(Some((target, Some(90.0))));
}
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
// anchor puts the player, facing it; else onto the spawn column.
let mut target = target;
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
return Ok((at, Some(yaw)));
return Ok(Some((at, Some(yaw))));
}
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
}
let y = self
.land_column(target.x.floor() as i32, target.z.floor() as i32)
.map_or(target.y, f64::from);
Ok((Vec3::new(target.x, y, target.z), None))
let (x, z) = (target.x.floor() as i32, target.z.floor() as i32);
// TeleportTransition.missingRespawnBlock: PlayerSpawnFinder around
// the world spawn.
if let Some(world) = self.level.world_spawn.filter(|w| w[0] == x && w[2] == z) {
return Ok(match self.search_spawn(world, finder) {
Ok(at) => Some((at, None)),
Err(chunk) => {
self.spawn_wait.push(chunk);
None
}
});
}
let y = self.land_column(x, z).map_or(target.y, f64::from);
Ok(Some((Vec3::new(target.x, y, target.z), None)))
}
/// Dying away from the Overworld respawns at the world spawn there.
+107 -8
View File
@@ -1,7 +1,7 @@
//! Fishing: `FishingRodItem.use` (cast / reel in), the `FishingHook` (flight,
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, view_vector};
use horizon_client::advancements::EntityView;
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
@@ -15,6 +15,7 @@ use horizon_client::{
};
use horizon_core::{
geometry::{Aabb, Vec3, trig},
position::BlockPos,
random::RandomSource,
};
use horizon_protocol::entity_data::{Value, serializer};
@@ -46,6 +47,13 @@ pub(super) struct Hook {
luck: i32,
lure: i32,
}
/// A player a hook can catch (`canHitEntity`): their entity id, which
/// guest they are (none: the host) and their box.
pub(super) struct HookTarget {
id: u32,
conn: Option<u32>,
bounds: Aabb,
}
const SIZE: f64 = 0.25;
fn bounds(p: Vec3) -> Aabb {
let h = SIZE / 2.0;
@@ -167,6 +175,11 @@ impl Game<'_> {
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
hooked_item = true;
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
// A player: the hub moves them when the tick ends.
self.creatures
.hook_pulls
.push((id, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1)));
}
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
let hooked = self
@@ -248,7 +261,7 @@ impl Game<'_> {
item,
[hook.position.x, hook.position.y, hook.position.z],
v,
10,
0,
);
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
@@ -339,9 +352,12 @@ impl Game<'_> {
let mut speed = 1;
let above = [b[0], b[1] + 1, b[2]];
let sky = self.world.light(above[0], above[1], above[2]) >> 4 == 15;
let raining = self.world.weather.raining;
let r = &mut self.world.random;
if r.next_float() < 0.25 && raining && sky {
// Level.isRainingAt: rain (not snow) reaches the position.
if self.world.random.next_float() < 0.25
&& self.with_level(|b, level, _| {
b.is_raining_at(level, BlockPos::new(above[0], above[1], above[2]))
})
{
speed += 1;
}
let r = &mut self.world.random;
@@ -472,6 +488,15 @@ impl Game<'_> {
if !self.holds_rod() || d2 > 1024.0 {
return;
}
// The players it can catch, as they stand now (the host's own hook;
// guests' hooks use the ones taken as their turns start).
if self.lan_acting.is_none()
&& matches!(h.state, State::Flying | State::Hooked(_))
&& let Some(host) = self.lan.take()
{
self.refresh_hook_targets(&host);
self.lan = Some(host);
}
h.previous = h.position;
if h.on_ground {
h.life += 1;
@@ -497,8 +522,8 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: hook the first living mob it touches, or (via
// `canHitEntity`) a dropped item.
// checkCollision: hook the first living mob or other player it
// touches, or (via `canHitEntity`) a dropped item.
let bb = bounds(h.position);
let (lo, hi) = (bb.min_position(), bb.max_position());
let touches = |b: Aabb| {
@@ -510,6 +535,14 @@ impl Game<'_> {
.iter()
.find(|m| m.alive() && touches(m.bounds()))
.map(|m| m.id)
.or_else(|| {
let owner = self.lan_acting;
self.creatures
.hook_targets
.iter()
.find(|t| t.conn != owner && touches(t.bounds))
.map(|t| t.id)
})
.or_else(|| {
self.dropped
.iter()
@@ -524,12 +557,25 @@ impl Game<'_> {
}
}
State::Hooked(id) => {
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
let hooked = self
.mobs
.iter()
.find(|m| m.id == id && m.alive())
.map(|m| (m.position, f64::from(m.height())))
.or_else(|| {
self.creatures
.hook_targets
.iter()
.find(|t| t.id == id)
.map(|t| {
let (lo, hi) = (t.bounds.min_position(), t.bounds.max_position());
(
Vec3::new((lo.x + hi.x) / 2.0, lo.y, (lo.z + hi.z) / 2.0),
hi.y - lo.y,
)
})
})
.or_else(|| {
self.dropped
.iter()
@@ -598,6 +644,59 @@ impl Game<'_> {
);
self.hook = Some(h);
}
/// The players a hook can catch: everyone but spectators and the dead
/// (`Projectile.canHitEntity`), the host and the guests in the level.
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
let mut targets = Vec::new();
if self.options.dedicated.is_none()
&& !self.spectator()
&& let Some(p) = self.player.as_ref()
&& !p.health.dead_or_dying()
{
targets.push(HookTarget {
id: host.host_entity as u32,
conn: None,
bounds: p.bounds(),
});
}
for g in &host.hub.guests {
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
targets.push(HookTarget {
id: g.entity_id as u32,
conn: Some(g.conn),
bounds: guest_bounds(g),
});
}
}
self.creatures.hook_targets = targets;
}
/// `FishingHook.pullEntity` on the players the hooks reeled in this tick
/// pulled: their velocity moves, and a guest's reaches their client.
pub(super) fn flush_hook_pulls(&mut self) {
let pulls = std::mem::take(&mut self.creatures.hook_pulls);
let Some(mut host) = self.lan.take() else {
return;
};
for (id, delta) in pulls {
let push = |g: &mut Self| {
if let Some(p) = g.player.as_mut() {
p.velocity = p.velocity.add_vector(delta);
}
};
if id == host.host_entity as u32 {
push(self);
} else if let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.entity_id as u32 == id)
.map(|g| g.conn)
{
self.guest_hit_by(&mut host, conn, push);
}
}
self.lan = Some(host);
}
/// `FishingRodCast`: the stack in the arm holding the rod
/// (`FishingHookRenderer.getHoldingArm`) while the hook is out.
pub(super) fn cast_rod(&self) -> Option<&ItemStack> {
+25 -1
View File
@@ -1,11 +1,13 @@
//! Field of view (`Camera.tickFov`, `calculateFov`,
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
//! (`GameRenderer.bobHurt`, `bobView`), the portal and Nausea spin
//! (`GameRenderer.renderLevel`) and the spyglass (`SpyglassItem`,
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
use super::Game;
use horizon_client::{
camera::{
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
spinning_effect_matrix,
},
fog::{FogType, fog_type},
hud::HudBatch,
@@ -26,6 +28,28 @@ impl Game<'_> {
pub(super) fn fov_effect_scale(&self) -> f32 {
self.settings.file.double("fovEffectScale") as f32
}
/// `options.screenEffectScale` (Distortion Effects).
pub(super) fn screen_effect_scale(&self) -> f32 {
self.settings.file.double("screenEffectScale") as f32
}
/// `LocalPlayer.getEffectBlendFactor(NAUSEA, partial)`: 0 without the effect.
pub(super) fn nausea_blend(&self, partial: f32) -> f32 {
if self.potions.effects.get("nausea").is_none() {
return 0.0;
}
let fx = &self.screen_fx;
fx.o_nausea + partial * (fx.nausea - fx.o_nausea)
}
/// The spin `GameRenderer.renderLevel` multiplies into the level's
/// projection: the larger of the portal and Nausea intensities, scaled by
/// Distortion Effects squared, turning by `LocalPlayer.getSpinningEffectAngle`.
pub(super) fn spinning_matrix(&self, partial: f32) -> glam::Mat4 {
let fx = &self.screen_fx;
let portal = fx.o_portal + partial * (fx.portal - fx.o_portal);
let scale = self.screen_effect_scale();
let intensity = portal.max(self.nausea_blend(partial)) * (scale * scale);
spinning_effect_matrix(intensity, fx.spinning_time + partial * fx.spinning_speed)
}
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
fn fov_player(&self) -> Option<FovPlayer> {
let p = self.player.as_ref()?;
+17 -1
View File
@@ -7,6 +7,7 @@ use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use std::sync::Arc;
@@ -223,6 +224,7 @@ impl Game<'_> {
}
let p = self.item_frames[i].pos;
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
self.frame_output_changed(p);
self.swing_arm();
true
}
@@ -255,8 +257,17 @@ impl Game<'_> {
}
self.drop_frame_items(&f, with_frame, true);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
true
}
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
/// the comparators reading the frame take its new signal.
fn frame_output_changed(&mut self, pos: [i32; 3]) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) {
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
@@ -300,6 +311,9 @@ impl Game<'_> {
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
continue;
}
}
@@ -415,7 +429,9 @@ impl Game<'_> {
if let Some(id) = map {
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
let depth = if f.invisible { 0.5 } else { 0.4375 };
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
// A glow frame's map is 15728850 = 0xF000D2 (sky coordinate 240,
// block coordinate 210), not full bright.
let item_light = if f.glow { 240 | 210 << 8 } else { packed };
let m = pose
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
* glam::DMat4::from_rotation_z(
Loaded 100 of 397 files, more files were not shown because too many files have changed in this diff. Show more