Compare commits

...
Author SHA1 Message Date
Kacper a5dbd2c5c1 Merge pull request #15240 from yahya-nusseibeh/fix/neoforge-version-path
Port-pack: only a plain NeoForge version makes the loader jar's path
2026-11-12 03:32:40 +01:00
Kacper 4c4de96fa5 Merge pull request #14760 from MSedgeMC/fix/walk-target-scores
A stroll scores its ground the way the jar does
2026-11-12 03:31:51 +01:00
Kacper c19aaf98e5 Merge pull request #14060 from MSedgeMC/fix/jigsaw-priority-tooltip
The jigsaw's priority boxes explain themselves on hover
2026-11-12 03:31:18 +01:00
Kacper 30a7b86bc8 Merge pull request #15296 from rdbtCVS/fix/deep-end-boat-void
Prevent deeply fallen boats from crashing dedicated servers
2026-11-12 03:18:14 +01:00
Kacper 0e0f95efa6 Merge pull request #15281 from Akirabane/fix/lan-accept-teleport-twice
LAN: a second accept of the same teleport id disconnects the guest, as vanilla does
2026-11-12 03:17:57 +01:00
Kacper 483497ef1c Merge pull request #14695 from ilguerro/fix/cave-air-is-air
Count cave air as air for open-water fishing and the flying kick
2026-11-12 03:17:40 +01:00
Kacper f7ba023dc6 Merge pull request #15435 from Akirabane/refactor/move-model-parts
Move the model parts out of mobs.rs into mobs/model.rs
2026-11-12 03:16:54 +01:00
Kacper cad46c5ff5 Merge pull request #15414 from mentallyunstable-lab/docs/layers-exceptions-15408
docs(layers): regenerate today's exceptions from tools/layers.json
2026-11-12 03:16:29 +01:00
Kacper 654bed3a7f Merge pull request #15248 from yahya-nusseibeh/fix/port-pack-licences
Port-pack: licence text stays one comment line, and families match whole words
2026-11-12 03:16:17 +01:00
Kacper 6154ce2282 Merge pull request #15160 from MSedgeMC/fix/brightness-gate
Level: getMaxLocalRawBrightness answers 15 outside the +-30,000,000 gate
2026-11-12 03:15:55 +01:00
Kacper 5e331b277a Merge pull request #15101 from angel2rider/tools/fixtures-ask-for-work-dir
tools: the command fixtures ask for their work directory instead of crashing
2026-11-12 03:15:33 +01:00
Kacper 96712da3cb Merge pull request #15091 from angel2rider/tools/port-ledger-json-after-query
tools: port_ledger.py accepts --json after the query's name, as its own help says
2026-11-12 03:15:22 +01:00
Kacper 755ba0b9a5 Merge pull request #15071 from MSedgeMC/fix/batch11-c
A tamed cat stops fleeing players
2026-11-12 03:15:11 +01:00
Kacper 29223492fc Merge pull request #15278 from DUzzL/fix/15270-alt-space
Swallow SC_KEYMENU so Alt+Space does not open the Windows system menu
2026-11-12 03:15:01 +01:00
Kacper d5f9e06dac Merge pull request #14902 from MSedgeMC/fix/chat-scroll-keeps-place
Changing the Chat option keeps the reader's place
2026-11-12 03:14:36 +01:00
Kacper 26d4f3f273 Merge pull request #14160 from ilguerro/fix/item-void-floor
Discard dropped items below each dimension's own void floor
2026-11-12 03:13:32 +01:00
Kacper fc71cbfeed Merge pull request #13755 from MSedgeMC/fix/follow-invisible
Follow-mob goals ignore invisible mobs
2026-11-12 03:13:02 +01:00
Kacper 0d55f3075b Merge pull request #15190 from MSedgeMC/fix/chunk-empty-sections
Chunk storage: a chunk without a sections list loads as an empty chunk
2026-11-12 02:54:55 +01:00
Kacper 24b778c3b4 Merge pull request #14173 from DUzzL/fix/14071-full-inventory-pickup
Sync a guest's refilled stack after a predicted drop
2026-11-12 02:45:28 +01:00
Kacper e1895229b8 Merge pull request #14499 from vnsmoreira/fix/explosion-player-xp
A player's blast pops the experience of what it breaks
2026-11-12 02:38:08 +01:00
Kacper cf79b811ac Merge pull request #13752 from DUzzL/fix/13665-crafted-stat
Award the crafted statistic for crafting-table and inventory-grid takes
2026-11-12 02:37:45 +01:00
Kacper 2b86defaa7 Merge pull request #13749 from MSedgeMC/fix/equippable-damage-on-hurt
Armor damage_on_hurt comes from the stack's equippable component
2026-11-12 02:37:35 +01:00
rdbt 0da40f990f Merge pull request #15493 from rdbtCVS/perf/mob-islands-to-main
Mobs: land the parallel mob islands on main (#15056 merged into the stack base)
2026-11-12 01:32:29 +00:00
Kacper d63ae80019 Merge pull request #15330 from Akirabane/fix/snow-golem-stroll-probability
A snow golem strolls with vanilla's rare probability, 1.0000001E-5
2026-11-12 02:26:31 +01:00
Kacper cd5c829460 Merge pull request #15318 from Akirabane/fix/new-world-folder-name
Create New World: the folder name is found as vanilla's findResultFolder finds it
2026-11-12 02:26:20 +01:00
Kacper 14ff11b96b Merge pull request #14651 from senjinthedragon/boat-passenger-water
Mobs: a mob sitting in a boat on water is not in the water, so a drowned or zombie in a boat burns in daylight
2026-11-12 02:25:43 +01:00
rdbtCVS 12bbd34fe5 Merge remote-tracking branch 'origin/main' into land-islands 2026-11-12 01:24:55 +00:00
Kacper 4a015bfb7c Merge pull request #15244 from yahya-nusseibeh/fix/servers-dat-lock
Client: hold the servers.dat lock on every save, each process with its own temp file
2026-11-12 02:24:25 +01:00
Kacper aaad7c57c1 Merge pull request #15237 from yahya-nusseibeh/fix/windows-console-close
Server: on Windows, wait for the clean stop when the console closes
2026-11-12 02:24:09 +01:00
Kacpherek 82a17c59ae Merge commit 'aab654d4e8' into unstick/14695
# Conflicts:
#	crates/horizon-client/src/lan/hub_tests.rs
2026-11-12 02:15:37 +01:00
Kacper 646722c466 Merge pull request #15290 from DUzzL/fix/15275-broken-stat
Count the broken statistic for every equipment break
2026-11-12 02:14:29 +01:00
Kacper 02f9ee4629 Merge pull request #14431 from Mayonesu0/up-13175-forced-chunk-ring
Rank a forced chunk's ring around it so it ticks on the first tick (#13175)
2026-11-12 02:14:03 +01:00
Kacper 902c147f8e Merge pull request #15231 from yahya-nusseibeh/fix/friends-try-every-endpoint
Friends: try every endpoint that answers before giving up on a direct join
2026-11-12 02:13:39 +01:00
Kacper 92166e6236 Merge pull request #14914 from vermingov/fix/axolotl-variant-14907
An axolotl with an unknown Variant id is lucy, not blue (#14907)
2026-11-12 02:13:22 +01:00
Kacpherek eb6536a832 Merge commit 'aab654d4e8' into unstick/14499
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-12 02:13:10 +01:00
Kacper cf26b92d45 Merge pull request #11548 from socksprox/items/destroy-progress
Move the mining speed formula into horizon-items
2026-11-12 02:13:04 +01:00
Kacpherek 687caa60c4 Merge commit 'aab654d4e8' into unstick/13755
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-12 02:08:55 +01:00
Kacpherek 9503016e93 Merge commit 'aab654d4e8' into unstick/13752
# Conflicts:
#	crates/horizon-client/src/menu/place/transfer_all_tests.rs
2026-11-12 02:07:56 +01:00
Kacpherek cce82d001c Merge commit 'aab654d4e8' into unstick/13749
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
2026-11-12 02:06:57 +01:00
Kacpherek f9d2eddb83 Merge commit 'aab654d4e8' into unstick/15190
# Conflicts:
#	crates/horizon-world/src/chunk.rs
2026-11-12 02:06:21 +01:00
Kacpherek d66d48d9a3 Merge commit 'aab654d4e8' into unstick/15278
# Conflicts:
#	crates/horizon-threads/src/lib.rs
2026-11-12 02:05:59 +01:00
Kacper aab654d4e8 Merge pull request #15314 from Akirabane/docs/port-map-game-012-fixed-gaps
port map: four rows of game-012.tsv now say what live goat and fox spawns do
2026-11-12 02:00:27 +01:00
Kacper b7eeb79a33 Merge pull request #15051 from ilguerro/fix/beacon-payment-order-tooltips
Beacon: pay emerald before diamond and tooltip every button
2026-11-12 02:00:15 +01:00
Kacper db7b03ffda Merge pull request #14571 from voidsipp/fix/11318-end-gateway-cooldowns
Use entity-specific End gateway cooldowns
2026-11-12 02:00:00 +01:00
Kacper e27a62210c Merge pull request #14373 from Joansitoh/fix/14368-spawnable-pos
Check only the spawnable bounds for /spawnpoint and /setworldspawn
2026-11-12 01:59:50 +01:00
Kacper 06e9cfe6da Merge pull request #14010 from ilguerro/fix/hoglin-pacified-breeding
Keep mobs/ai.rs from growing: the hoglin check moves to piglin.rs
2026-11-12 01:58:54 +01:00
Kacper b6941b78f2 Merge branch 'main' into fix/snow-golem-stroll-probability 2026-11-12 01:55:53 +01:00
Kacper d64883cef3 Merge branch 'main' into fix/lan-accept-teleport-twice 2026-11-12 01:55:43 +01:00
Kacper 7ab3a23f95 Merge branch 'main' into fix/new-world-folder-name 2026-11-12 01:55:34 +01:00
Kacper 07050a34bd Merge pull request #15420 from yahya-nusseibeh/fix/launcher-data-link-above-root
Launcher: follow links above the data folder when moving it
2026-11-12 01:48:21 +01:00
Kacper 9054341b05 Merge pull request #15417 from AlfaRojo/fix/fishing-hook-harness-peaceful
fishing_hook_entities: run the servers in peaceful
2026-11-12 01:48:02 +01:00
Kacper 30db6aa7fd Merge pull request #14473 from voidsipp/split/11469-damage-source-foundation
Preserve explicit explosion damage sources
2026-11-12 01:46:47 +01:00
Kacper 663e7457ab Merge pull request #14260 from ilguerro/fix/void-floor-per-dimension
Start the void 64 blocks below each level's own floor
2026-11-12 01:45:31 +01:00
Kacper 2b375bf38c Merge pull request #15421 from vnsmoreira/packs/core-provided-blocks
Core shaders: blocks and samplers a pipeline provides, and blocks on one line
2026-11-12 01:36:40 +01:00
Kacper 172cec105a Merge pull request #15201 from DUzzL/fix/15134-flame-particles
Particles face the frame's camera, not the player's yaw and pitch
2026-11-12 01:36:24 +01:00
rdbt 5b28083ef3 Merge pull request #15481 from rdbtCVS/server/engine-parallel-tick
Server: [engine] parallel_tick turns the parallel tick on from server.toml
2026-11-12 00:36:18 +00:00
Kacper 3072b90817 Merge pull request #14885 from vermingov/fix/fireball-power-byte-14883
A fireball narrows a loaded ExplosionPower to a byte (#14883)
2026-11-12 01:36:07 +01:00
Kacper 4c29ce57c7 Merge pull request #13820 from RINNECODER/up12/ctm-chain-and-pass
OptiFine CTM: a picked tile chains to the rules naming it, and draws in its face's pass
2026-11-12 01:35:49 +01:00
rdbt 291b2c2b03 Merge pull request #14552 from rdbtCVS/perf/mob-loop-group
Mobs: the mob loop runs as a group of mobs, still on one thread (parallel mob tick, draft)
2026-11-12 00:35:30 +00:00
rdbt 9fa96cab6f Merge pull request #14531 from rdbtCVS/perf/ordered-draws
Random: one LegacyRandom stream drawn in list order from several threads (parallel mob tick, draft)
2026-11-12 00:34:44 +00:00
rdbt 892c64cc69 Merge pull request #14529 from rdbtCVS/perf/item-moves-ahead
Parallel tick: the dropped items' own moves worked out ahead on helpers
2026-11-12 00:34:14 +00:00
rdbtCVSandClaude Sonnet 5.5 6fad4fb984 Server: [engine] parallel_tick in server.toml turns the parallel tick on
A host with a fixed startup command (a game panel running `./horizon-server run`) can't set
HORIZON_PARALLEL_TICK. `[engine] parallel_tick = true` makes horizon-server start the game with the
variable set to 1, the way `low_memory` sets MIMALLOC_ALLOW_THP. Off leaves the variable alone, so one
already in the environment still applies.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:30:19 +00:00
Kacper 32ed49b6d8 Merge pull request #15191 from MSedgeMC/fix/disabledlabel
A disabled button's label is grey
2026-11-12 01:24:41 +01:00
Kacper 35992267b9 Merge pull request #15150 from MSedgeMC/fix/jigsaw-local-edit
fix: a jigsaw edited in single player is applied
2026-11-12 01:24:24 +01:00
Kacper 95d025f4c2 Merge pull request #14566 from DUzzL/fix/14461-firework-dispenser-rotation
Turn a shot firework rocket to its motion like Projectile.shoot
2026-11-12 01:24:07 +01:00
Kacper 40b9d160c9 Merge pull request #13747 from MSedgeMC/fix/loom-dye-component
A loom reads a dye stack's dye component
2026-11-12 01:23:50 +01:00
Kacper 984872a0dd Merge pull request #15313 from AlfaRojo/fix/driven-mount-client-hurt
A driven horse takes no fall damage on its rider's client
2026-11-12 01:20:14 +01:00
Kacper 4d00052e33 Merge pull request #14179 from voduff/frames-projectile-hits
Projectiles hit item frames: the item comes out, then the frame breaks
2026-11-12 01:19:48 +01:00
Kacper cc15269fb6 Merge branch 'main' into fix/deep-end-boat-void 2026-11-12 01:07:37 +01:00
Kacper 1177119180 Merge branch 'main' into fix/15275-broken-stat 2026-11-12 01:07:28 +01:00
Kacper 2bfb755693 Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-12 01:06:06 +01:00
Kacper 3f287372f4 Merge branch 'main' into boat-passenger-water 2026-11-12 01:03:40 +01:00
Kacper d3e0272348 Merge branch 'main' into fix/11318-end-gateway-cooldowns 2026-11-12 01:03:18 +01:00
Kacper 818e0a229e Merge branch 'main' into split/11469-damage-source-foundation 2026-11-12 01:02:42 +01:00
Kacper 09fa6403c5 Merge branch 'main' into up-13175-forced-chunk-ring 2026-11-12 01:02:33 +01:00
Kacper cad3d55f76 Merge branch 'main' into fix/14368-spawnable-pos 2026-11-12 01:02:24 +01:00
Kacper 0d85f3f65f Merge branch 'main' into fix/void-floor-per-dimension 2026-11-12 01:01:57 +01:00
Kacper 39046b18e8 Merge branch 'main' into fix/item-void-floor 2026-11-12 01:01:48 +01:00
Kacper 46d26691cf Merge branch 'main' into fix/explosion-player-xp 2026-11-12 01:00:07 +01:00
Kacper 713fe9176d Merge branch 'main' into packs/core-provided-blocks 2026-11-12 01:00:07 +01:00
Kacper 66319c12e6 Merge branch 'main' into fix/launcher-data-link-above-root 2026-11-12 00:59:58 +01:00
Kacper 31b0e68219 Merge branch 'main' into fix/windows-console-close 2026-11-12 00:59:42 +01:00
Kacper 8bf7dd6dfe Merge branch 'main' into fix/15134-flame-particles 2026-11-12 00:59:34 +01:00
Kacper 5814b1cb5f Merge branch 'main' into fix/friends-try-every-endpoint 2026-11-12 00:59:25 +01:00
Kacper 59cd7697be Merge branch 'main' into fix/jigsaw-local-edit 2026-11-12 00:59:21 +01:00
Kacper 063632d5f5 Merge branch 'main' into fix/axolotl-variant-14907 2026-11-12 00:59:12 +01:00
Kacper 8424af6894 Merge branch 'main' into fix/fireball-power-byte-14883 2026-11-12 00:59:04 +01:00
Kacper a0d2e7d935 Merge branch 'main' into fix/14071-full-inventory-pickup 2026-11-12 00:58:52 +01:00
Kacper 7e779ceb8b Merge branch 'main' into fix/loom-dye-component 2026-11-12 00:58:28 +01:00
Kacper 5481445402 Merge branch 'main' into up12/ctm-chain-and-pass 2026-11-12 00:58:12 +01:00
Kacper 2c62ea5aaa Merge branch 'main' into items/destroy-progress 2026-11-12 00:58:10 +01:00
Kacper 0bd5635d74 Merge branch 'main' into fix/hoglin-pacified-breeding 2026-11-12 00:58:04 +01:00
Kacper 53ef669745 Merge branch 'main' into fix/14461-firework-dispenser-rotation 2026-11-12 00:57:55 +01:00
Kacper 44d13ad5d4 Merge branch 'main' into fix/disabledlabel 2026-11-12 00:57:23 +01:00
Kacper f66e1a8d91 Merge branch 'main' into fix/servers-dat-lock 2026-11-12 00:57:15 +01:00
Kacper 634df8744a Merge branch 'main' into fix/fishing-hook-harness-peaceful 2026-11-12 00:57:06 +01:00
Kacper e8b23d06ed Merge branch 'main' into fix/jigsaw-priority-tooltip 2026-11-12 00:56:34 +01:00
Kacper c1894da34b Merge branch 'main' into fix/walk-target-scores 2026-11-12 00:56:08 +01:00
Kacper 0482839c1b Merge branch 'main' into fix/neoforge-version-path 2026-11-12 00:55:35 +01:00
Kacper 09fdaa392a Merge branch 'main' into fix/batch11-c 2026-11-12 00:55:01 +01:00
Kacper 6e5270105b Merge branch 'main' into tools/port-ledger-json-after-query 2026-11-12 00:54:52 +01:00
Kacper 7c9db3e706 Merge branch 'main' into tools/fixtures-ask-for-work-dir 2026-11-12 00:54:44 +01:00
Kacper e8b01b7282 Merge branch 'main' into fix/port-pack-licences 2026-11-12 00:54:27 +01:00
Kacper f22a814607 Merge branch 'main' into refactor/move-model-parts 2026-11-12 00:54:19 +01:00
Kacper b14027d065 Merge branch 'main' into fix/brightness-gate 2026-11-12 00:53:53 +01:00
Kacper 5133e20195 Merge branch 'main' into docs/layers-exceptions-15408 2026-11-12 00:53:44 +01:00
AkirabaneandClaude Sonnet 5.5 3e75afe983 port_map: the goat and fox rows of game-012.tsv name the callers that exist
Goat.finalizeSpawn, GoatAi.initMemories and Fox.populateDefaultEquipmentSlots
said that Ctx::finalize and Ctx::populate call goat_spawn and
fox::populate_default_equipment, and that goat_spawn calls goat_init_memories.
They do not: Ctx::finalize and Ctx::populate have their own copies of the same
draws, and goat_spawn draws the two cooldowns itself.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 00:27:54 +01:00
Akirabane 29df2938b1 Merge origin/main into docs/port-map-game-012-fixed-gaps 2026-11-12 00:27:11 +01:00
Kacpherek 0808d1f189 Merge commit '3298a6dcd5' into unstick/14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-11 23:59:55 +01:00
Kacper 5a4d8c11e6 Merge branch 'main' into fix/deep-end-boat-void 2026-11-11 23:58:23 +01:00
Kacpherek 219d6b6b4e Keep mobs.rs from growing: the client_side note sits on its field's line 2026-11-11 23:57:12 +01:00
Kacpherek 251c800717 Close the chat option change test in chat_tests 2026-11-11 23:53:01 +01:00
Kacper 02b3da3eec Merge branch 'main' into fix/explosion-player-xp 2026-11-11 23:52:32 +01:00
Kacpherek 4676650694 Merge commit '3298a6dcd5' into unstick/15313 2026-11-11 23:50:19 +01:00
Kacpherek 660288e50b Merge commit '3298a6dcd5' into unstick/14902 2026-11-11 23:50:18 +01:00
Kacper 53ffc05840 Merge branch 'main' into packs/core-provided-blocks 2026-11-11 23:50:04 +01:00
Kacper 5b275cd324 Merge branch 'main' into fix/launcher-data-link-above-root 2026-11-11 23:49:56 +01:00
Kacper 05c0a3942f Merge branch 'main' into fix/servers-dat-lock 2026-11-11 23:49:35 +01:00
Kacper fa34419e4b Merge branch 'main' into fix/fishing-hook-harness-peaceful 2026-11-11 23:49:32 +01:00
Kacper a640a5a91a Merge branch 'main' into fix/windows-console-close 2026-11-11 23:49:26 +01:00
Kacper 7475a27b7b Merge branch 'main' into fix/friends-try-every-endpoint 2026-11-11 23:49:17 +01:00
Kacper 721a16e17b Merge branch 'main' into fix/15134-flame-particles 2026-11-11 23:49:09 +01:00
Kacper 20c3b24dab Merge branch 'main' into fix/disabledlabel 2026-11-11 23:49:00 +01:00
Kacper 8d60a65c6c Merge branch 'main' into fix/jigsaw-priority-tooltip 2026-11-11 23:48:59 +01:00
Kacper 72d6a5b383 Merge branch 'main' into fix/jigsaw-local-edit 2026-11-11 23:48:51 +01:00
Kacper b1104143ef Merge branch 'main' into fix/14461-firework-dispenser-rotation 2026-11-11 23:48:42 +01:00
Kacper 1c98b67b44 Merge branch 'main' into fix/14071-full-inventory-pickup 2026-11-11 23:48:33 +01:00
Kacper 1299432e7a Merge branch 'main' into fix/walk-target-scores 2026-11-11 23:48:32 +01:00
Kacper 271bace8e1 Merge branch 'main' into fix/hoglin-pacified-breeding 2026-11-11 23:48:24 +01:00
Kacper dec1f7bd51 Merge branch 'main' into up12/ctm-chain-and-pass 2026-11-11 23:48:06 +01:00
Kacper 3943570b53 Merge branch 'main' into fix/neoforge-version-path 2026-11-11 23:47:58 +01:00
Kacper a5b58a242b Merge branch 'main' into fix/loom-dye-component 2026-11-11 23:47:56 +01:00
Kacper 6494dc92e0 Merge branch 'main' into items/destroy-progress 2026-11-11 23:47:39 +01:00
Kacper 7cae9ff3c4 Merge branch 'main' into fix/batch11-c 2026-11-11 23:47:08 +01:00
Kacper d9d0c7ad13 Merge branch 'main' into tools/port-ledger-json-after-query 2026-11-11 23:47:00 +01:00
Kacper d60a13d0d3 Merge branch 'main' into tools/fixtures-ask-for-work-dir 2026-11-11 23:46:51 +01:00
Kacper 38e2c2522a Merge branch 'main' into fix/port-pack-licences 2026-11-11 23:46:35 +01:00
Kacper 8c46ceea46 Merge branch 'main' into refactor/move-model-parts 2026-11-11 23:46:26 +01:00
Kacper 0f209ac875 Merge branch 'main' into fix/brightness-gate 2026-11-11 23:46:00 +01:00
Kacper de7ceb78d7 Merge branch 'main' into docs/layers-exceptions-15408 2026-11-11 23:45:51 +01:00
Kacper 28f0c9802e Merge branch 'main' into fix/driven-mount-client-hurt 2026-11-11 23:23:37 +01:00
Kacper e1a97b5783 Merge branch 'main' into fix/15275-broken-stat 2026-11-11 23:23:28 +01:00
Kacper 1ec82f9a9f Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-11 23:22:05 +01:00
Kacper d62d4787ab Merge branch 'main' into fix/chat-scroll-keeps-place 2026-11-11 23:21:20 +01:00
Kacper c0cfcda90e Merge branch 'main' into boat-passenger-water 2026-11-11 23:19:33 +01:00
Kacper 68d5cd7114 Merge branch 'main' into fix/11318-end-gateway-cooldowns 2026-11-11 23:18:51 +01:00
Kacper 37d6df3219 Merge branch 'main' into split/11469-damage-source-foundation 2026-11-11 23:18:16 +01:00
Kacper 59b2aed66c Merge branch 'main' into up-13175-forced-chunk-ring 2026-11-11 23:17:56 +01:00
Kacper cb8761a18c Merge branch 'main' into fix/14368-spawnable-pos 2026-11-11 23:17:38 +01:00
Kacper f5dbdcadba Merge branch 'main' into fix/void-floor-per-dimension 2026-11-11 23:17:13 +01:00
Kacper d361eaf394 Merge branch 'main' into frames-projectile-hits 2026-11-11 23:17:03 +01:00
Kacper fe4d3a2e94 Merge branch 'main' into fix/item-void-floor 2026-11-11 23:16:54 +01:00
Akirabane 1b5be38573 Merge remote-tracking branch 'origin/main' into local/M1-move-model-parts 2026-11-11 23:14:52 +01:00
Akirabane 2106e9b21e Merge remote-tracking branch 'origin/main' into local/R13-snow-golem-stroll 2026-11-11 23:12:46 +01:00
Akirabane b1cdc91bf4 Merge remote-tracking branch 'origin/main' into local/R09-world-folder-name 2026-11-11 23:11:43 +01:00
Akirabane e7cdaa120a Merge remote-tracking branch 'origin/main' into local/PM4-game-012 2026-11-11 23:10:40 +01:00
Akirabane 58a9cbddca Merge remote-tracking branch 'origin/main' into local/R16-accept-teleport 2026-11-11 23:09:47 +01:00
Kacpherek 56d067048f Merge commit '6cc36c6f3a' into unstick/15296
# Conflicts:
#	crates/horizon-client/src/game/boats/below_world.rs
2026-11-11 22:29:50 +01:00
Kacpherek b5c00e8040 Merge commit '6cc36c6f3a' into unstick/14914
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:24 +01:00
Kacpherek 23ae7d42c2 Merge commit '6cc36c6f3a' into unstick/14885
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-11 22:24:15 +01:00
Kacpherek 1aa3ad9441 Merge commit 'a5df8273bb' into unstick/14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/frames.rs
2026-11-11 22:06:31 +01:00
Kacpherek fc67c07589 Merge commit 'a5df8273bb' into unstick/14571
# Conflicts:
#	crates/horizon-client/src/game/entity.rs
2026-11-11 22:04:59 +01:00
Vinícius Moreira a4266531f2 Merge commit 'c8e1eb66199f696d1a6218fbbaeef1863ce5cc00' into fix/explosion-player-xp
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-11 17:57:09 -03:00
Kacper bb6b34d530 Merge branch 'main' into fix/15275-broken-stat 2026-11-11 21:51:13 +01:00
Kacper be2f80a4cf Merge branch 'main' into fix/beacon-payment-order-tooltips 2026-11-11 21:50:39 +01:00
Kacper 12e1d88d71 Merge branch 'main' into fix/chat-scroll-keeps-place 2026-11-11 21:50:20 +01:00
Kacper 1a9bf76f69 Merge branch 'main' into fix/item-void-floor 2026-11-11 21:49:44 +01:00
yahya-nusseibeh 715402eb80 Merge remote-tracking branch 'upstream/main' into fix/port-pack-licences 2026-11-11 22:43:21 +02:00
yahya-nusseibehandClaude Opus 5.5 18e26a3e4d Port pack: count a family with its version run on as that family
The whole-word match turned GPLv3, LGPLv3, AGPLv3 and MPLv2 into custom: their token is
gplv3, not gpl. A token now matches a family when it is the family's name followed by
nothing, or by an optional v and digits. Tests for those spellings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 22:43:18 +02:00
Kacpherek 95756af921 Merge commit 'a5df8273bb' into unstick/15313
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-11 21:42:52 +01:00
Kacpherek 72e4d61b57 Merge commit 'a5df8273bb' into unstick/14651
# Conflicts:
#	crates/horizon-client/src/mobs/living_tests.rs
2026-11-11 21:42:15 +01:00
Kacpherek 6604778b58 Merge commit 'a5df8273bb' into unstick/14431
# Conflicts:
#	crates/horizon-client-render/src/streaming.rs
2026-11-11 21:39:33 +01:00
Kacpherek ce25b504c8 Merge commit 'a5df8273bb' into unstick/14260
# Conflicts:
#	crates/horizon-client/src/projectiles.rs
2026-11-11 21:36:40 +01:00
Kacpherek 5a281cec36 Merge commit 'a5df8273bb' into unstick/14473
# Conflicts:
#	crates/horizon-client/src/game/explosion.rs
2026-11-11 21:36:25 +01:00
Akirabane 33a70e3fa4 Merge remote-tracking branch 'origin/main' into local/R13-snow-golem-stroll 2026-11-11 21:33:28 +01:00
Akirabane f5ea4bde3a Merge remote-tracking branch 'origin/main' into local/R09-world-folder-name 2026-11-11 21:33:25 +01:00
Akirabane 251976843a Merge remote-tracking branch 'origin/main' into local/PM4-game-012 2026-11-11 21:33:23 +01:00
Akirabane 8e17b81335 Merge remote-tracking branch 'origin/main' into local/R16-accept-teleport 2026-11-11 21:33:21 +01:00
rdbtCVSandGPT 6.1 Sol 9ac80b72b7 Move command position validation out of oversized dispatcher
Address the maintainer request on PR #14373 by relocating the loaded and spawnable block-position helpers into a sibling module. Keep both implementations and their game-module visibility unchanged.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:31:23 +00:00
AkirabaneandClaude Sonnet 5.5 a8d3ffaf2e Move the model parts out of mobs.rs into mobs/model.rs
Polygon, ModelNormals and Part move byte for byte; only visibility changes
(the private members the sibling modules reach become pub(super)), and mobs.rs
declares the module and re-exports Part and Polygon. A port map row follows the
move. mobs.rs goes from 4931 to 4720 lines without test modules, so a change
to Seen has room.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 21:23:03 +01:00
Kacpherek f2b07381ab Merge main into pr-13755
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-11 20:55:08 +01:00
Vinícius MoreiraandClaude Opus 5.5 bee546c085 Core shaders: blocks and samplers a pipeline provides, and blocks on one line
A pack's core shader could read only vanilla's blocks and samplers. A
pipeline that gives it more (the improved-transparency stages' depth bounds
and coefficients for core/text, Polytone's blocks) translates it with
translate_core_with and the names it provides; CoreProgram::other lists
them in binding order, at other_binding(i). A uniform block declared on one
line is bound like one over several lines, where it came out unbound and
the shader was refused. An unprovided one is named as the pack declared it.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 16:53:50 -03:00
Kacpherek 80392718df Merge main into fix/15134-flame-particles 2026-11-11 20:45:10 +01:00
Kacpherek 122a7650ad Merge main into fix/disabledlabel 2026-11-11 20:45:03 +01:00
Kacpherek c4c5649ce9 Merge main into fix/chunk-empty-sections 2026-11-11 20:44:56 +01:00
Kacpherek da7c9d05b3 Merge main into fix/brightness-gate 2026-11-11 20:44:48 +01:00
Kacpherek 938c04c32e Merge main into fix/jigsaw-local-edit 2026-11-11 20:44:34 +01:00
Kacpherek ebcd7beedb Merge main into tools/fixtures-ask-for-work-dir 2026-11-11 20:44:19 +01:00
Kacpherek 1e45ee6ed3 Merge main into tools/port-ledger-json-after-query 2026-11-11 20:44:12 +01:00
Kacpherek 8bf3aa3982 Merge main into fix/batch11-c 2026-11-11 20:44:04 +01:00
Kacpherek 7c723354eb Merge main into fix/walk-target-scores 2026-11-11 20:43:06 +01:00
Kacpherek c9675c1ba5 Merge main into fix/14461-firework-dispenser-rotation 2026-11-11 20:42:52 +01:00
Kacpherek debbbf6585 Merge main into fix/jigsaw-priority-tooltip 2026-11-11 20:42:16 +01:00
yahya-nusseibeh 9e701221fd Merge upstream main into fix/servers-dat-lock 2026-11-11 21:32:53 +02:00
yahya-nusseibeh 5a76ab5f51 Merge upstream main into fix/neoforge-version-path 2026-11-11 21:32:42 +02:00
yahya-nusseibeh ef218f6ced Merge upstream main into fix/windows-console-close 2026-11-11 21:32:37 +02:00
yahya-nusseibeh 8e4d76ef04 Merge upstream main into fix/friends-try-every-endpoint 2026-11-11 21:32:29 +02:00
yahya-nusseibehandClaude Opus 5.5 a8f543b1cc Launcher: follow links above the data folder when moving it
Moving the data folder was refused when any folder above a data root was a symbolic link (a
~/.local/share on another disk, Fedora Atomic's /home). Only the roots are copied, so only the
root itself may not be a link.

Split out of #15247 at review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 21:31:44 +02:00
Kevin RamirezandClaude Opus 5.5 ad8dd2f57e fishing_hook_entities: run the servers in peaceful
The flat world lies below y 40 and seed 12345 has slime chunks within
the view distance, so either server may spawn a slime
(Slime.checkSlimeSpawnRules) during the minute the steps take. On
Horizon one did: it pushed the boat off the bobber's line before the
cast and killed Alice, so the boat was never hooked. Fishing hooks do
not depend on the difficulty; peaceful keeps every monster out.

Fixes #15323

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 13:25:20 -06:00
mentallyunstable-labandClaude Opus 5.5 018ca6a76e docs(layers): regenerate today's exceptions from tools/layers.json
The table said 47 exceptions at d5504cd328 and still listed edges that
are gone (the horizon-client-input row; -theme, -bench, -telemetry and
-mod-api-gen for the render crate). Rebuild it from the 30 entries in
'allowed' at a89f338 and point at tools/check_layers.py for the live
count.

Closes #15408

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 21:10:55 +02:00
Kevin Ramirez 242fba5b5a Merge upstream main for CI (play_ping_tests fix) 2026-11-11 13:07:53 -06:00
AkirabaneandClaude Sonnet 5.5 f19afb61ad Merge origin/main into local/R16-accept-teleport
The conflict was the AcceptTeleportation arm in lan/hub.rs: the handler stays in
hub/accept_teleport.rs, which takes main's change of the guest's prediction
record (g.record instead of g.anticheat.record).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 17:14:23 +01:00
Kevin RamirezandClaude Opus 5.5 d9eab08f7f A driven horse takes no fall damage on its rider's client
Entity.hurt only calls hurtServer on a server level. The rider's client
ticks the horse it drives with the server's mob code, so a fall, lava or
suffocation hurt and killed the client's copy: it lay dead on the ground
while the server's horse lived, and could be ridden again (#15294).

Fixes #15294

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 09:34:53 -06:00
AkirabaneandClaude Sonnet 5.5 c6f61bbcc4 port_map: the goat and fox spawn rows of game-012.tsv name the live spawn path
Goat.finalizeSpawn, GoatAi.initMemories and Fox.populateDefaultEquipmentSlots
are reached by live spawns; Fox.finalizeSpawn keeps the one gap that is left.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 16:29:41 +01:00
rdbtCVSandCodex gpt-6.1-sol 70865d77e8 Fix deeply fallen boat loading and dedicated-server void crashes
Match vanilla saved boat bounds and void removal, and make chunk and collision coordinate arithmetic safe at integer boundaries.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-11 14:58:17 +00:00
Riccardo Guerriero d526f569a0 Merge origin/main into fix/beacon-payment-order-tooltips 2026-11-11 15:57:04 +01:00
Riccardo Guerriero 957ba89eac Merge origin/main into fix/item-void-floor 2026-11-11 15:57:04 +01:00
DUzzL c4146a25af Count the broken statistic for every equipment break
ServerPlayer.onEquippedItemBroken awards Stats.ITEM_BROKEN whatever the
slot. Award it in player_equipment_broke instead of at two call sites, so
worn armour, shields and enchantment-driven breaks count too.
2026-11-11 15:33:49 +01:00
yahya-nusseibehandClaude Opus 5.5 c59deaa19b Port-pack: only a plain NeoForge version makes the loader jar's path
A modpack's NeoForge version (modrinth.index.json dependencies.neoforge, CurseForge, modlist)
went unchecked into loader_jar's file name and URL. A version such as
"21.1.1/neoforge-21.1.1-universal.jar#/../../../../<dir>/x" made fetch create every directory
on that path and rename a real NeoForge jar over <dir>/x-universal.jar, with no checksum
(the .sha1 lookup got the jar and failed).

loader_jar now takes only [A-Za-z0-9._+-]{1,64} without "..", and the loader jar is downloaded
only with a 40-hex SHA-1 from Maven.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 16:06:13 +02:00
AkirabaneandClaude Sonnet 5.5 fb6b8c54e2 A snow golem strolls with the probability vanilla gives it
SnowGolem.registerGoals builds its WaterAvoidingRandomStrollGoal with the
three-argument constructor and 1.0000001E-5F, not the default 0.001 that
Stroll::avoiding_water uses.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 15:00:03 +01:00
AkirabaneandClaude Sonnet 5.5 d1a243289c LAN: a second accept of the same teleport id disconnects
ServerGamePacketListenerImpl.handleAcceptTeleportPacket: an accept of the last
teleport id sent with no teleport awaited is invalid player movement. The
handler moves into hub/accept_teleport.rs so hub.rs does not grow.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 14:47:49 +01:00
DUzzL eb18b83652 Swallow SC_KEYMENU so Alt+Space does not open the Windows system menu 2026-11-11 14:46:32 +01:00
AkirabaneandClaude Opus 5.5 c4889805f3 Create New World: name the folder as findResultFolder does
WorldCreationUiState.findResultFolder trims the name (String.trim), takes the translated
selectWorld.newWorld when nothing is left, and asks FileUtil.findAvailableName for a free folder:
sanitizeName turns only SharedConstants.ILLEGAL_FILE_CHARACTERS and . / " into _, a name Windows
reserves is wrapped in underscores, a name ending in " (n)" counts on from n, the name is cut to
255 characters with its count, and a count that can't be parsed falls back to "World".

Horizon turned every character but letters, digits, space, - and _ into _ (Bob's world! became
Bob_s world_), named a blank world's folder World, and counted every taken name up from 1. The
rules move out of title.rs, a big file, into title/world_folder.rs with their test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 14:40:07 +01:00
rdbtCVSandGPT 6.1 Sol b52d83f71e fix(ui): wrap and anchor focused jigsaw priority tooltips
Match Tooltip.splitTooltip, WidgetTooltipHolder and BelowOrAboveWidgetTooltipPositioner. Keep real hover distinct from the hidden keyboard/controller pointer and test priority label/value/packet mapping.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:23:09 +00:00
rdbtCVSandGPT 6.1 Sol 72c066552e Merge main into hoglin feeding fix, preserving the extracted predicate
Keep main's takes_food extraction and canAgeUp baby check, and apply the contributor's pacified-hoglin breeding gate in ai/feeding.rs. Normalize two test citations to the vanilla method names.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 13:09:38 +00:00
yahya-nusseibehandClaude Opus 5.5 3f254035ec Port-pack: licence text stays one comment line, and families match whole words
- A jar's licence text (and file name) went into a `#` comment of the carried mod's
  horizon-mod.toml as it was: a line break ended the comment and wrote keys of its own. Control
  characters are now replaced with spaces.
- license_kind matched families by substring, so "limited" read as MIT (permissive), "template"
  as MPL (copyleft) and "Discord" as ISC. It now matches whole words of the text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 15:01:06 +02:00
yahya-nusseibehandClaude Opus 5.5 545b8f9e8d Client: hold the servers.dat lock on every save, each process with its own temp file
servers::lock() keeps a ping storing an icon and a save of the list from undoing each other,
but adding or editing an entry on the screen, and the gameplay saves of a server's resource
pack choice and Code of Conduct, loaded and saved without it: a favicon saved meanwhile could
drop the edit, or the edit the icon. Every writer also used the same servers.dat_tmp, so
overlapping writes could rename an interleaved file into place, which then loads as an empty
list and is saved as one.

Those three now hold the lock (the screen releases it before reloading the list), and the
temporary file is named per process.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 14:19:49 +02:00
yahya-nusseibehandClaude Opus 5.5 c2e172a8a3 Server: on Windows, wait for the clean stop when the console closes
For CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT and CTRL_SHUTDOWN_EVENT, Windows ends the process as
soon as the console handler returns, so the supervisor died right after counting the request
and never stopped its game. The game runs in its own process group and gets no event: it kept
the world and the port, unsupervised, until it was killed without a save.

The handler now waits, within what Windows allows (4 s for a close, 15 s for a logoff or
shutdown), until the supervisor reports with signals::stopped() that its game has stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:49:20 +02:00
DUzzL 9147b0893b Particles face the frame's camera, not the player's own yaw and pitch 2026-11-11 12:29:42 +01:00
yahya-nusseibehandClaude Opus 5.5 004628230c Friends: try every endpoint that answers before giving up on a direct join
join_direct gave the handshake only to the first endpoint that answered the hello, and returned
its error with `?`, so the invite's other endpoints and its relay were never tried. Invites
carry the host's LAN addresses on the default port, and on the guest's own network another
Horizon host at the same private address (a housemate's game) can answer first: a valid code
failed as "The code doesn't match".

Each answering endpoint now gets the handshake in turn until one takes the code. When none
does, the relay is tried as before; without a relay, the wrong code (else the first failure)
is reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-11 13:27:42 +02:00
TheGT 75cfb4cc28 Merge remote-tracking branch 'origin/main' into tools/fixtures-ask-for-work-dir 2026-11-11 11:23:39 +00:00
TheGT dd60b729ee Merge remote-tracking branch 'origin/main' into tools/port-ledger-json-after-query 2026-11-11 11:22:13 +00:00
MSedgeMC 651abefe2b port_map: WithInactiveMessage's grey label 2026-11-11 11:14:29 +00:00
MSedgeMC 8858d0c7e0 test: a disabled button's label is grey
The label's Draw::Text colour is 0xFFA0A0A0 when the button is inactive.
2026-11-11 11:14:29 +00:00
MSedgeMC df589fa039 test: a chunk without sections loads as an empty chunk
Without the change `ChunkData::new` answers
`Err(InvalidData, "chunk is missing sections")`.
2026-11-11 11:05:38 +00:00
MSedgeMC 2c8c04518d fix: a chunk without a sections list loads as a chunk with no sections
`SerializableChunkData.parse` reads the section list with
`getListOrEmpty("sections")`, so a chunk that does not carry one is a chunk with
no sections rather than an unreadable one. `ChunkData::new` refused it, so an
empty or hand-written chunk could not be opened; it now keeps an empty list in
the tag, which is what the writer would put there and what the accessors below
look for.
2026-11-11 11:05:37 +00:00
MSedgeMC 226d535e77 fix: a disabled button's label is grey
AbstractWidget.WithInactiveMessage.getMessage returns
ComponentUtils.mergeStyles(message, Style.EMPTY.withColor(-6250336)) while the
widget is inactive, so a disabled button's message is drawn grey (0xFFA0A0A0).
screen_draw::button drew the same white label in both states; it now colours
the label when inactive.
2026-11-11 10:35:57 +00:00
MSedgeMC 2aeaeb2495 test: the +-30,000,000 gate answers 15, inside answers the raw brightness
`TestLevel` gains a `sky_light` override so the two answers differ (0 inside,
15 outside); without the change both read 0.
2026-11-11 09:24:13 +00:00
MSedgeMC 782331b499 fix: full daylight outside the +-30,000,000 gate in getMaxLocalRawBrightness
`LevelReader.getMaxLocalRawBrightness(pos)` answers 15 when the position is
outside `x >= -30000000 && z >= -30000000 && x < 30000000 && z < 30000000`,
because there is no chunk to ask there; inside it is `getRawBrightness(pos,
skyDarken)`. Horizon always asked the level, so past the gate it returned
whatever the (unloaded, dark) level answered: saplings and mushrooms never grow
and nothing that reads the brightness sees daylight.
2026-11-11 09:24:13 +00:00
MSedgeMC 4256dec408 port_map: handleSetJigsawBlock applies locally too
The guest path marks the block entity; the integrated game applies the
same packet through the editor's Done.
2026-11-11 08:33:44 +00:00
MSedgeMC 959c3b6180 test: a single-player jigsaw edit applies and marks the chunk
`a_local_jigsaw_edit_applies_and_marks_the_chunk` sends a
`SetJigsawBlock` through the editor's Done path on an integrated game:
the block entity takes the new name and priorities and its chunk is
marked. Without the local application the name stays the default.
2026-11-11 08:33:44 +00:00
MSedgeMC e00864ba59 fix: a jigsaw edited in single player is applied
`ServerboundSetJigsawBlockPacket` is handled on the server
(`handleSetJigsawBlock`), which writes the block entity and marks it. The
editor's Done only builds that packet, and `Game::send` drops it when
there is no remote (the integrated server is this game), so a jigsaw edit
in single player changed nothing.

`block_editor_outcome` now applies the packet locally when `remote` is
none, through the same `apply_set_jigsaw_block` a guest's edit uses.
2026-11-11 08:33:23 +00:00
TheGT ee8d4c4a72 Merge remote-tracking branch 'origin/main' into tools/port-ledger-json-after-query 2026-11-11 07:36:06 +00:00
TheGTandOpenCode 8e4e46fdd6 tools: the command fixtures ask for their work directory instead of crashing
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:36:10 +00:00
TheGTandOpenCode 9351cd381f tools: port_ledger.py accepts --json after the query's name, as its own help says
Co-Authored-By: OpenCode <[email protected]>
2026-11-11 04:26:33 +00:00
MSedgeMC 72e3ec1924 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:38 +00:00
MSedgeMC 278dee9871 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:53:02 +00:00
MSedgeMC 7a8364a0f7 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-11 03:52:53 +00:00
MSedgeMC c96f20ac6c port_map: map CatAvoidEntityGoal continue 2026-11-11 03:23:17 +00:00
MSedgeMC fb6707ec92 test: a tamed cat stops fleeing players 2026-11-11 03:23:17 +00:00
MSedgeMC dfc7fdd786 fix: a tamed cat stops fleeing players 2026-11-11 03:23:02 +00:00
Vinícius Moreira 541bc4b519 Merge remote-tracking branch 'upstream/main' into fix/explosion-player-xp 2026-11-11 00:03:46 -03:00
Vinícius MoreiraandClaude Opus 5.5 b5c4b2f84b Wrap the zombie owner's summon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:52 -03:00
Vinícius MoreiraandClaude Opus 5.5 75ef2801f5 A nested blast drops the TNT owner; the zombie owner stays and leaves no drops
A native explosion inside a TNT blast is Level.explode(null, ...): it
now takes blast_owner, blast_guest and blast_owner_player aside with
the rest of the blast's state. The group keeps its zombie persistent
and clears the zombie's drops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:43:46 -03:00
Vinícius MoreiraandClaude Opus 5.5 699cf1101f A mob-owned TNT's blast pops no block experience
PrimedTnt.getOwner names the mob a saved owner UUID points at, so
dropExperience (the indirect source is a player) is false. Keep
whether the owner is a player beside blast_owner (a chain reaction
hands a mob owner on as a mob), and compare only whether orbs came
out, since they may merge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:33:19 -03:00
MSedgeMC 07af125581 Merge main; keep both sides' tests and module declarations 2026-11-11 01:02:53 +00:00
Vinícius MoreiraandClaude Opus 5.5 5e5258f410 A player's blast pops the experience of what it breaks
Block.onExplosionHit passes dropExperience = the explosion's indirect
source is a player (a primed TNT's owner) to spawnAfterBreak, so ores,
sculk and spawners blasted by a player's TNT sample and pop their
experience while blocks drop. Horizon popped none.

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:59:33 -03:00
MSedgeMC d9672a3587 Merge remote-tracking branch 'upstream/main' into fix/jigsaw-priority-tooltip
# Conflicts:
#	tools/port_map/client2-016.tsv
2026-11-11 00:48:02 +00:00
DUzzL 27b18c07f0 Merge remote-tracking branch 'upstream/main' into rv/r14566 2026-11-11 00:45:19 +01:00
MSedgeMC a06a1853e2 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-10 23:21:13 +00:00
DUzzL 67e0a30ea8 Merge remote-tracking branch 'upstream/main' into rv/r14566 2026-11-10 23:59:43 +01:00
DUzzL 02d449ced2 Merge upstream/main into fix/14461-firework-dispenser-rotation 2026-11-10 23:59:29 +01:00
MSedgeMC 2e8cc55201 Merge remote-tracking branch 'upstream/main' into fix/walk-target-scores
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-11-10 22:47:33 +00:00
c4ec5dfd57 Beacon: pay emerald before diamond and tooltip every button
`BeaconScreen.extractBackground` draws the payment row as netherite (x+20),
emerald (x+41), diamond (x+41+22), gold (x+42+44) and iron (x+42+66); the row
no longer follows the items' value. `BeaconPowerButton.setEffect` gives every
power button its effect's tooltip, and `BeaconUpgradePowerButton`'s
`createEffectDescription` appends " II". `BeaconSpriteScreenButton`'s tooltip
is its label (`CommonComponents.GUI_DONE` / `GUI_CANCEL`), hovered whether or
not the button is active.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:27:10 +01:00
Verm fca27e4c63 An axolotl with an unknown Variant id is lucy, not blue (#14907)
Axolotl.Variant.BY_ID is OutOfBoundsStrategy.ZERO: an id outside 0..4
is the first variant, lucy. The loader shared the parrot's arm, whose
CLAMP is right for the parrot but made Variant:7 the rare blue axolotl.
2026-11-10 17:33:46 +00:00
Verm f61130508b A fireball narrows a loaded ExplosionPower to a byte (#14883)
LargeFireball.readAdditionalSaveData reads it with getByteOr, which
narrows any numeric tag (IntTag.byteValue is (byte)(value & 255)):
ExplosionPower:300 loads as 44. The loader kept the raw number, so
such a fireball exploded with power 300 while its save showed 44b.
2026-11-10 17:04:53 +00:00
MSedgeMC 7ab5bfd238 port_map: ChatComponent.refreshTrimmedMessages keeps the scroll 2026-11-10 16:40:49 +00:00
MSedgeMC e347c6f99f test: a Chat option change keeps the reader's place
Sixty lines, the reader three lines back with a new message since: a Chat
Visibility change leaves both, a rescale resets both.
2026-11-10 16:40:45 +00:00
MSedgeMC f03a2ffe21 fix: changing the Chat option keeps the reader's place
ChatComponent.setVisibleMessageFilter rewraps through refreshTrimmedMessages,
which leaves chatScrollbarPos alone; only rescaleChat resets it first
(resetChatScroll). Horizon's rewrap always reset the scroll and
newMessageSinceScroll, so changing Chat Visibility threw a scrolled-back
reader to the bottom.
2026-11-10 16:40:25 +00:00
MSedgeMC d46bea7851 port_map: the stroll's score names its port
The mushroom cow's row said its mycelium rule lived only in the spawn check;
walk_target_value carries it now, and the enderman, the pillager and the
warden have their flat 0 beside the other walk-target rows.
2026-11-10 10:53:43 +00:00
MSedgeMC a25182804a Test the walk-target scores of four classes
walk_target_value at three positions that differ only in the block below: a
cow scores grass 10.0 and stone and mycelium by the light, a mushroom cow
mycelium 10.0 and grass by the light (never 10), a zombie the negated light,
and an enderman, a pillager and a warden 0.0 on all three. Without the two
rules the mushroom cow reads 0.5 for its mycelium, the light term, and the
three flat classes read the zombie's score.
2026-11-10 10:50:20 +00:00
MSedgeMC dd178e638f A stroll scores its ground the way the jar does
PathfinderMob.getWalkTargetValue is what a random stroll ranks its ten
candidate positions by, and three of its class overrides had no port:
Enderman.getWalkTargetValue, Pillager's and Warden's answer 0 wherever the
position is, so their strolls pick one of the ten at random, while Horizon
scored them by the negated light and sent them to the dark. A mushroom cow's
override replaces Animal's outright — mycelium 10, everything else by the
light — so grass is worth nothing to it, while Horizon gave it the cow's grass
10 and nothing for its own mycelium.

walk_target_value moves out of ai.rs into mobs/ai/walk_target.rs with the two
rules, and ai.rs loses 75 lines of code to the move.
2026-11-10 10:50:20 +00:00
4b8f61ed0e Count cave air as air for open-water fishing and the flying kick
FishingHook.getOpenWaterTypeForBlock tests !state.isAir()
(FishingHook.java:416), and isAir() covers air, cave_air and void_air;
ServerGamePacketListenerImpl.noBlocksAround filters on the same predicate
(ServerGamePacketListenerImpl.java:543). open_water_type now reads
BlockFacts::is_air, and no_blocks_in reads the hub's state table, so a hook
under a mineshaft's air counts as open water (treasure can be caught) and a
survival guest hovering there is kicked for flying after 80 ticks.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-10 04:58:50 +01:00
Senjin 1f7d2adcbe Mobs: a mob sitting in a boat on water is not in the water, so a drowned or zombie in a boat burns in daylight
AbstractBoat.modifyPassengerFluidInteractionBox for mobs: a boat that isn't underwater keeps only the part of its passenger's box above the boat's top, and all of it when the boat reaches the passenger's head. Players had it (motion/fluids.rs); mobs measured their whole box, so a passenger's legs below the surface made it count as in the water: it never burned in daylight, the flow pushed it and boarding splashed.
2026-11-10 02:49:35 +01:00
voidsip fc83f106f2 Encode gateway fixture positions as NBT double bits 2026-11-09 18:04:41 -06:00
voidsip d1d35697a1 Clarify the gateway phase fixture validation boundary 2026-11-09 17:52:57 -06:00
voidsip 6a9154801f Check real gateway cooldown dispatch before and after movement 2026-11-09 17:51:55 -06:00
DUzzL 72c06622be Turn a shot firework rocket to its motion like Projectile.shoot 2026-11-10 00:25:30 +01:00
voidsip ca8cc5a43b Prove nested native damage cannot inherit an outer credited source 2026-11-09 16:17:25 -06:00
Mayonesu0andClaude Sonnet 5.5 ac2a95ab1d Keep streaming.rs from growing: the forced ring's ranking basis lives in forced_ring (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Mayonesu0andClaude Sonnet 5.5 faef0581d1 Test that queued jobs follow a forced chunk (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
Mayonesu0andClaude Sonnet 5.5 4df6ddc3f3 Rank a forced chunk's ring around it (#13175)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 22:31:34 +01:00
JoansitohandClaude Sonnet 5.5 644d598685 Check only the spawnable bounds for /spawnpoint and /setworldspawn
Vanilla resolves the position with BlockPosArgument.getSpawnablePos: no
loaded-chunk or build-height check, only Level.isInSpawnableBounds, else
argument.pos.outofbounds.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 21:21:36 +01:00
voidsip afd4df1cd9 Use entity-specific End gateway cooldowns 2026-11-09 14:20:42 -06:00
voidsip 37249efd34 Check explicit blast context survives a nested native blast 2026-11-09 14:15:06 -06:00
Horizon agent 98498da9aa Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 21:14:49 +01:00
voidsip a2b63b3e3b Keep explicit damage source state across nested native explosions 2026-11-09 14:13:34 -06:00
voidsip 1f3f7cebae Separate explicit explosion damage causes from blast ownership 2026-11-09 14:11:42 -06:00
Horizon agent 316bef4484 Merge origin/main (brings #14090's test_git_slim fix) 2026-11-09 21:04:19 +01:00
Horizon agent f3fdefa0ec Merge remote-tracking branch 'origin/main' into rpn4-14179
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-09 19:56:12 +01:00
voduffandClaude Opus 5.5 7837d91790 Projectiles hit item frames: the item comes out, then the frame breaks (#11383)
Frames are pickable (BlockAttachedEntity.isPickable), so a projectile
meets them and ItemFrame.hurtServer runs: a filled frame gives its item
up on any non-explosion hit, an empty one breaks, a fixed one refuses
unless the shooter is creative (and a refused arrow is turned back).
Horizon left frames out of every projectile's targets.

projectile_targets/vehicle_targets move to creatures/projectile_targets.rs
and list frames; attack_frame's body becomes hit_frame, shared with the
new projectile_hurts_frame, which arrows, tridents and hurt_vehicle's
callers reach. New harness test_frame_projectile_parity.py (27/27;
main: 22/27).

Part of #11383

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:49:21 +00:00
DUzzL 53b7a6768d Record the predicted drop as sent so a refilling pickup syncs to a guest 2026-11-09 18:47:18 +01:00
Riccardo GuerrieroandClaude Opus 5.5 9d025d7e3c Start the void 64 blocks below each level's own floor
Entity.checkBelowWorld acts below Level.getMinY() - 64. Horizon used the
chunks' column bottom, which is widened to the Overworld's -64 in every
level, so in the Nether and the End (min Y 0) players and mobs only took
void damage, and arrows, thrown potions and other projectiles were only
removed, below -128 instead of -64; a guest's death there was not named
"fell out of the world". World::below_world_y gives the level's own floor
minus 64, and an entity exactly on it stays, as vanilla's strict < keeps it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:47:16 +01:00
945c667281 Discard dropped items below each dimension's own void floor
Entity.checkBelowWorld discards at getY() < Level.getMinY() - 64, so the Nether and the End (minY 0) lose an item at y = -64, not the Overworld's -128. Use the level's minimum y in ItemEntity.tick and the item tick, and keep an item at exactly the threshold, as vanilla's < does.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Claude-Session: https://claude.ai/code/session_01WZzq8MxShENtiMj2DMbm5G
2026-11-09 18:10:14 +01:00
MSedgeMC 8d7267f7f7 port_map: JigsawBlockEditScreen lives in jigsaw_edit.rs 2026-11-09 16:51:30 +00:00
MSedgeMC 0eb2196f99 The jigsaw's priority boxes explain themselves on hover
JigsawBlockEditScreen sets a tooltip on its selection and placement priority
boxes, explaining what each number does. Horizon's jigsaw editor drew both
boxes without one. The block editor now takes an optional tooltip per box and
draws it over the screen, and the jigsaw gives its two priority boxes theirs.
2026-11-09 16:21:58 +00:00
Horizon agentandClaude Opus 5.5 26afbb999c Merge #13745's formatting into #13747
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:52 +01:00
Horizon agentandClaude Opus 5.5 d906242b0e Format the pattern tag walk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:45 +01:00
Horizon agentandClaude Opus 5.5 dbf99fe456 Merge #13748 into #13749
Both add an equippable reader beside `equippable` in item_sync.rs: with
#13748 merged in, either lands first without a conflict. The new test's
doc comment now describes it, and the glider test keeps its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:40:24 +01:00
Horizon agentandClaude Opus 5.5 7842fca4da Keep the glider test's doc on it; cover a removed equippable
The new test went between the glider test's doc comment and its fn.
Also check that a removed component plays no equip sound, as the
description says.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:57 +01:00
Horizon agentandClaude Opus 5.5 ef01959fb1 Merge #13745 (with #13744) into #13747
The three loom PRs change banner.rs side by side: with the others merged
in, any of them can land first without a conflict. All their tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:39 +01:00
Horizon agentandClaude Opus 5.5 ec2cd929ab Merge #13744 into #13745; read nested pattern tags
Both PRs change the loom's component reads in banner.rs: merging #13744
here lets either land first without a conflict (both tests kept).

provides_banner_patterns names `#minecraft:pattern_item/<name>`, a tag in
a subfolder, and an id may omit its namespace: load every tag under
tags/banner_pattern and namespace bare ids (Identifier.parse), with
test cases for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:06 +01:00
Horizon agentandClaude Opus 5.5 6f79c55c2a Keep game.rs at its size: leave the drop check in place there
game.rs is over the 3000-line limit (docs/layers.md#file-size); the two forms are the same check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:36:42 +01:00
Horizon agent 529cd743a0 Merge remote-tracking branch 'origin/main' into fix-11548
# Conflicts:
#	crates/horizon-client/src/game/hand.rs
2026-11-09 16:35:26 +01:00
MSedgeMC e632dda2b7 cargo fmt 2026-11-09 14:07:35 +00:00
MSedgeMC d22f313e9e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-09 14:00:02 +00:00
MSedgeMC e957a4bd85 Keep mobs.rs and goals.rs under the file-size limit
Mob::seen moves to mobs/seen.rs and the FollowMob test to mobs/invisible_follow_tests.rs, so neither file over
3000 lines grows: 6858 -> 6777 and 3740 -> 3738.
2026-11-09 13:05:29 +00:00
RINNECODERandClaude Opus 5.5 89f90f0b65 OptiFine CTM: a tile draws in the pass of the face it replaces
CTM picks a texture, not a render layer: OptiFine draws a tile in the
block's layer. Horizon took the pass from the tile's own sprite, so one
of Conquest Reforged's six random grass blade tiles (7 partly clear
texels of 1024) put its overhang plane in the translucent pass. That
pass has no alpha test, so the plane's clear rectangle wrote depth
(depthtex0 had it, depthtex1 not), and Bliss fogged it: faint
rectangles against the sky. A tile now takes the face's pass; its own
texels still decide whether it skips the alpha test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:50:07 -06:00
RINNECODERandClaude Opus 5.5 f965ae85f3 OptiFine CTM: a picked tile chains to the rules naming it, not to its layer's
Identical still sprites share one layer. Chaining looked the picked tile's
rules up by that layer, so a tile chained to every rule naming a tile with
the same pixels. Conquest's grass top repeats 16 blank placeholders that
random rules beside them replace (matchTiles=./N.png); dozens of other
folders' placeholders are blank too, and their rules, sorting first, drew
their own tiles on the grass. A rule's tile now chains to the rules naming
that tile, and a tile file (not a model's sprite) no longer matches faces by
layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:50:07 -06:00
Riccardo GuerrieroandClaude Opus 5.5 983d9a3235 Keep mobs/ai.rs from growing: the hoglin check moves to piglin.rs
ai.rs is over the 3000-line limit (docs/layers.md#file-size), so the
Hoglin.canFallInLove check and its explanation move to
piglin::refuses_love; takes_food keeps one line for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:08:18 +01:00
MSedgeMC 87c473666a Do not load items the selection test does not read 2026-11-09 09:03:32 +00:00
MSedgeMC 1c9f8a4359 Keep the tag type in the test 2026-11-09 09:03:30 +00:00
MSedgeMC d0850614ba Follow mob goals ignore invisible mobs 2026-11-09 08:50:42 +00:00
MSedgeMC f4d10e7099 Import the Tag type the loom component read uses 2026-11-09 08:48:45 +00:00
MSedgeMC 1b029a08f6 Import the Tag type the loom component read uses 2026-11-09 08:48:43 +00:00
MSedgeMC 8a5211f260 Import the Tag type the loom component read uses 2026-11-09 08:48:41 +00:00
MSedgeMC 0831968204 Armor damage_on_hurt comes from the stack's equippable component
LivingEntity.doHurtEquipment skips a piece whose Equippable.damageOnHurt is
false. Horizon read the prototype's flag for both the player's armor and a
mob's, so a stack patched to false still wore.

Test: a golden helmet patched with damage_on_hurt:0b does not wear, and a
removed equippable component does not wear.
2026-11-09 08:43:33 +00:00
MSedgeMC 1e8745416d Equip sounds come from the stack's equippable component
Player and mount equipping read the item prototype's equip_sound. A stack's
own minecraft:equippable replaces the component, so its equip_sound (or the
generic default when omitted) must win.

Test: a golden helmet with a slot-only equippable component uses the generic
sound, not the gold one; a patch naming a sound uses that sound.
2026-11-09 08:43:26 +00:00
MSedgeMC 9fc9722501 A loom reads a dye stack's dye component
LoomMenu.isDyeItem needs the LOOM_DYES tag and a present DYE component, and
the result uses that component's colour. Horizon read the prototype's dye,
so a patched or removed dye kept its item default.

Test: white dye patched red colours red; white dye with !minecraft:dye is not
a loom dye.
2026-11-09 08:43:19 +00:00
MSedgeMC adfd8e43c8 A loom offers the patterns a stack's component names
LoomMenu.getSelectablePatterns reads the stack's PROVIDES_BANNER_PATTERNS
HolderSet. Horizon read the item prototype's list, so a patched pattern item
offered the wrong patterns.

Test: a globe banner pattern patched to creeper offers creeper, and a removed
component offers none.
2026-11-09 08:43:11 +00:00
MSedgeMC f7267efa1b A loom takes a pattern item whose provides component was removed
LoomMenu.isPatternItem needs the LOOM_PATTERNS item tag and a present
PROVIDES_BANNER_PATTERNS component. Horizon accepted any tagged item whose
prototype provided patterns, even after !minecraft:provides_banner_patterns.

Test: a globe banner pattern with the component removed is not a pattern
item.
2026-11-09 08:43:04 +00:00
DUzzL d288db6b6e Award the crafted statistic for crafting-table and inventory-grid takes 2026-11-09 09:18:02 +01:00
ede2ada183 Make a pacified hoglin refuse breeding food
A hoglin within reach of a #hoglin_repellents block keeps its PACIFIED
memory for 200 ticks, and Hoglin.canFallInLove is false while it is set.
Animal.mobInteract then refuses the crimson fungus: nothing is eaten, no
love mode, no baby. Its baby branch asks canAgeUp instead, so a pacified
baby still grows up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-09 07:23:48 +01:00
socksproxandClaude Opus 5.5 507006a08d Move the mining speed formula into horizon-items
Player.getDestroySpeed, Player.hasCorrectToolForDrops and
BlockBehaviour.getDestroyProgress move from horizon-client's gameplay into
horizon_items::mining, taking plain inputs, so frontends that cannot link
horizon-client mine by the same rules. destroy_progress in game/hand.rs
gathers the player's modifiers and calls it, with the native_break_speed
hook still where getDestroySpeed ends; break_block_as_player uses the same
harvest check. Same f32 operations in the same order: no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:24:09 +01:00
151 changed files with 5484 additions and 901 deletions

No files matched your search

+12 -5
View File
@@ -568,6 +568,8 @@ impl Chat {
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
if self.qol != qol {
self.qol = qol;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
}
@@ -593,20 +595,25 @@ impl Chat {
.filter(|q| !q.trim().is_empty());
if self.search != query {
self.search = query;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(font);
}
}
pub fn searching(&self) -> bool {
self.search.is_some()
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
/// `resetChatScroll`, then the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.rewrap(&atlas.font);
}
fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
/// where the reader was (`chatScrollbarPos` survives a filter change);
/// `rescale` resets the scroll itself, as `rescaleChat` does.
fn rewrap(&mut self, font: &crate::font::Font) {
self.lines.clear();
let mut messages = std::mem::take(&mut self.messages);
for m in messages.iter_mut().rev() {
@@ -1067,6 +1067,34 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
);
}
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
/// `rescaleChat` resets them, after `resetChatScroll`.
#[test]
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
let font = font();
let mut chat = Chat::default();
for i in 0..60 {
chat.add_message(
&font,
Text::literal(format!("line {i}")),
None,
ChatSource::Server,
0,
false,
);
}
chat.scroll = 3;
chat.new_since_scroll = true;
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
assert_eq!(chat.scroll, 3);
assert!(chat.new_since_scroll);
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
chat.rescale(&atlas);
assert_eq!(chat.scroll, 0);
assert!(!chat.new_since_scroll);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
+20 -2
View File
@@ -196,7 +196,7 @@ impl ColumnLayout {
}
/// The index of the section holding block `y`, if the column holds it.
pub fn section_index(self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
.ok()
.filter(|&s| s < self.sections)
}
@@ -700,7 +700,7 @@ impl Chunk {
/// The index of the section holding block `y`, if the column holds it.
#[inline]
pub fn section_index(&self, y: i32) -> Option<usize> {
usize::try_from((y - self.min_y) >> 4)
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
.ok()
.filter(|&s| s < self.sections.len())
}
@@ -984,6 +984,24 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
let mut chunk = Chunk::with_layout(0, 0, layout);
chunk.set(0, layout.min_y(), 0, 7);
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
assert_eq!(layout.section_index(y), None);
assert_eq!(chunk.section_index(y), None);
assert_eq!(chunk.get(0, y, 0), 0);
assert_eq!(
chunk.light(0, y, 0),
if y < layout.min_y() { 0 } else { 0xf0 }
);
chunk.set(0, y, 0, 9);
}
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
}
}
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
+11 -2
View File
@@ -419,8 +419,10 @@ impl SceneCollisions<'_, '_> {
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
let cells = [size(lo.x, hi.x), size(lo.y, hi.y), size(lo.z, hi.z)]
.into_iter()
.try_fold(1i64, |n, size| n.checked_mul(size));
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
return false;
}
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
@@ -1111,6 +1113,13 @@ mod section_tests {
binding: &binding,
subject: None,
};
// The fast path must decline an enormous volume without overflowing
// its size calculation or beginning to walk it.
let enormous = Aabb::new(
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
Vec3::new(1.0e9, 1.0e9, 1.0e9),
);
assert!(!scene.cells_without_collision(enormous));
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
+85 -14
View File
@@ -56,6 +56,8 @@ pub struct Overlay {
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
chain: Vec<(u32, u32)>,
overlay: OverlayJoin,
}
@@ -188,6 +190,7 @@ impl ConnectedTextures {
Ok(tiles) => Some(LoadedRule {
rule,
tiles,
chain: Vec::new(),
overlay: OverlayJoin::default(),
}),
Err(e) => {
@@ -292,10 +295,23 @@ impl ConnectedTextures {
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
if let Some(l) = layer(&sprite_name(tile)) {
let name = sprite_name(tile);
let list = by_name.entry(name.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
// name. By layer it would match every face whose sprite has the same pixels
// (identical stills share a layer: Conquest's blank placeholders, dozens).
if !name.contains(':') {
continue;
}
if let Some(l) = layer(&name) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
@@ -316,6 +332,26 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let by_name: HashMap<String, (u32, u32)> = by_name
.into_iter()
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
.collect();
// Set at the end: `by_block` borrows the rules' names.
let chains: Vec<Vec<(u32, u32)>> = out
.rules
.iter()
.map(|r| {
r.rule
.tiles
.iter()
.map(|t| match t {
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
_ => None,
})
.map(Option::unwrap_or_default)
.collect()
})
.collect();
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
@@ -355,6 +391,9 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
for (r, chain) in out.rules.iter_mut().zip(chains) {
r.chain = chain;
}
out
}
@@ -392,9 +431,11 @@ impl ConnectedTextures {
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
ranges.get(i).map_or(&[], |&range| self.range(range))
}
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
&self.lists[start as usize..(start + len) as usize]
}
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
@@ -441,7 +482,7 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) = self.apply(
&& let Some((pick, index)) = self.apply(
r,
state,
layer,
@@ -454,7 +495,7 @@ impl ConnectedTextures {
)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile (and this block, if they name blocks).
// tile rules naming the picked tile by name (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
@@ -467,7 +508,7 @@ impl ConnectedTextures {
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.list(&self.by_layer, picked as usize)
.range(r.chain.get(index).copied().unwrap_or_default())
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
@@ -488,8 +529,8 @@ impl ConnectedTextures {
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
Some((Pick::Keep, _)) => return pick,
Some((chained, _)) => return chained,
None => {}
}
}
@@ -512,7 +553,7 @@ impl ConnectedTextures {
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
) -> Option<(Pick, usize)> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
@@ -602,7 +643,7 @@ impl ConnectedTextures {
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some(Pick::Quarters(quarters))
Some((Pick::Quarters(quarters), 0))
};
}
}
@@ -617,7 +658,7 @@ impl ConnectedTextures {
return None;
}
if !joins([0, 1, 0]) {
return Some(Pick::Keep);
return Some((Pick::Keep, 0));
}
0
}
@@ -644,8 +685,8 @@ impl ConnectedTextures {
}
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
TileLayer::Default => Some((Pick::Keep, index)),
t => Some((Pick::Tile(t), index)),
}
}
}
@@ -675,6 +716,7 @@ mod tests {
.enumerate()
.map(|(n, rule)| LoadedRule {
overlay: OverlayJoin::default(),
chain: Vec::new(),
tiles: rule
.tiles
.iter()
@@ -722,6 +764,10 @@ mod tests {
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
"assets/minecraft/optifine/ctm/grass/1.png"
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
_ => None,
},
)
@@ -1176,6 +1222,31 @@ mod tests {
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn chaining_follows_the_tile_not_its_pixels() {
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
// first; it must not draw on the grass.
let ctm = world(&[
(
"grass/top.properties",
"matchTiles=glass\nmethod=fixed\ntiles=1",
),
(
"grass/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
),
(
"aaa/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
),
]);
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// A face whose own sprite has a placeholder's pixels is no placeholder.
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
+115 -17
View File
@@ -56,6 +56,15 @@ pub struct Glue<'a> {
pub struct CoreProgram {
pub vertex: naga::Module,
pub fragment: naga::Module,
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
/// binding unused).
pub other: Vec<String>,
}
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
pub fn other_binding(index: usize) -> u32 {
OTHER_BINDINGS + 2 * index as u32
}
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
@@ -66,6 +75,20 @@ pub fn translate_core(
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
) -> Result<CoreProgram, String> {
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
}
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
pub fn translate_core_with(
vertex: &str,
fragment: &str,
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
provided: &dyn Fn(&str) -> bool,
) -> Result<CoreProgram, String> {
const CORE: &str = "minecraft:shaders/core/";
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
@@ -75,10 +98,16 @@ pub fn translate_core(
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
.map_err(|e| format!("fragment shader: {e}"))?;
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
.map_err(|e| format!("fragment shader: {e}"))?;
let fragment = compile(
ShaderStage::Fragment,
&fragment_code,
defines,
&names,
provided,
)
.map_err(|e| format!("fragment shader: {e}"))?;
let given = locations(&vertex, false);
if let Some(missing) = locations(&fragment, true)
.into_iter()
@@ -88,7 +117,11 @@ pub fn translate_core(
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
));
}
Ok(CoreProgram { vertex, fragment })
Ok(CoreProgram {
vertex,
fragment,
other: names.other,
})
}
/// A bound on a pack's includes, which are pasted every time.
@@ -309,7 +342,9 @@ fn block_name(line: &str) -> Option<&str> {
let rest = line[at + "uniform".len()..].trim();
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
let (name, tail) = rest.split_at(name_end);
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
.then_some(name)
}
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
@@ -337,7 +372,7 @@ impl Names {
self.other.len() - 1
}
};
OTHER_BINDINGS + 2 * index as u32
other_binding(index)
}
}
@@ -551,7 +586,13 @@ fn rename_main(text: &str) -> String {
}
/// Parses and validates one stage; errors name the line of the rewritten code.
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
fn compile(
stage: ShaderStage,
code: &str,
defines: &[&str],
names: &Names,
provided: &dyn Fn(&str) -> bool,
) -> Result<naga::Module, String> {
let mut options = Options::from(stage);
for define in defines {
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
@@ -583,16 +624,16 @@ fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Mod
})?;
let entry = info.get_entry_point(0);
for (handle, global) in module.global_variables.iter() {
let other = global
.binding
.as_ref()
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
if other && !entry[handle].is_empty() {
let name = global.name.as_deref().unwrap_or("?");
let name = name
.strip_prefix("hz_tex_")
.or_else(|| name.strip_prefix("hz_smp_"))
.unwrap_or(name);
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
continue;
};
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
// The name it was declared with (a block's instance may have none).
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
let name = names.other.get(index).map_or("?", String::as_str);
if provided(name) {
continue;
}
return Err(format!("{name} is not a block or sampler of this pipeline"));
}
}
@@ -868,6 +909,63 @@ mod tests {
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
}
/// A block declared on one line, as packs write a one-float uniform, is bound like one
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
/// order, and one it doesn't provide is not.
#[test]
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
let both = |name: &str| name == "Day" || name == "Bounds";
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
assert_eq!(program.other, ["Day", "Bounds"]);
// The bindings the caller gives them at.
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
module
.global_variables
.iter()
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
.collect()
};
assert!(
bindings(&program.vertex)
.iter()
.any(|(_, b)| *b == other_binding(0)),
"{:?}",
bindings(&program.vertex)
);
let fragment = bindings(&program.fragment);
assert!(
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
"{fragment:?}"
);
assert!(
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
"{fragment:?}"
);
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
assert!(error.contains("Day is not a block or sampler"), "{error}");
let only_day = |name: &str| name == "Day";
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
assert!(
error.contains("Bounds is not a block or sampler"),
"{error}"
);
}
/// Only a uniform block's declaration names a block, written over several lines or on one.
#[test]
fn block_names_are_read_from_block_declarations_only() {
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
assert_eq!(block_name("uniform Globals"), Some("Globals"));
assert_eq!(block_name("uniform float x;"), None);
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
}
#[test]
fn what_horizon_does_not_provide_is_an_error() {
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
@@ -396,6 +396,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
other: Vec::new(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_some());
@@ -417,6 +418,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
other: Vec::new(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_none());
+55 -21
View File
@@ -2259,22 +2259,14 @@ impl Mesher<'_> {
translucent: face.translucent,
opaque: face.opaque,
};
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
let tile = |t: TileLayer| match t {
TileLayer::Layer {
layer,
translucent,
opaque,
} => TileLayer::Layer {
layer,
translucent: translucent || q.render_type.is_none() && q.material.force_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
};
let own_translucent = self.translucent(own);
let tile = |t: TileLayer| in_face_pass(t, own, own_translucent);
match pick {
Pick::Keep => self.quad(out, force_solid, own, corners.map(|c| c.0), tint),
Pick::Tile(t) => self.quad(out, force_solid, tile(t), corners.map(|c| c.0), tint),
Pick::Tile(t) => {
let v = corners.map(|c| c.0);
self.quad_as(out, force_solid, tile(t), v, false, tint);
}
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
@@ -2283,7 +2275,7 @@ impl Mesher<'_> {
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
let piece = piece.map(|c| c.0);
self.quad(out, force_solid, tile(tiles[quarter]), piece, tint);
self.quad_as(out, force_solid, tile(tiles[quarter]), piece, false, tint);
}
}
}
@@ -2296,12 +2288,7 @@ impl Mesher<'_> {
if count == 0 {
return;
}
let face_translucent = match pick {
Pick::Keep => self.translucent(own),
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
Pick::Nothing => false,
};
let face_translucent = !matches!(pick, Pick::Nothing) && own_translucent;
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
// rotations).
@@ -2450,6 +2437,21 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
}
out
}
/// Tile `t` as drawn over a face whose own sprite is `own`, in the face's pass
/// (`own_translucent`): CTM picks a texture, not a render layer (OptiFine draws a tile in the
/// block's). The tile's texels only drop the alpha test when all are opaque. A Conquest grass
/// blade tile with 7 partly clear texels of 1024 blended, and wrote the depth of its clear
/// rectangle, which Bliss fogs. `<skip>` and `<default>` are the face's own sprite.
fn in_face_pass(t: TileLayer, own: TileLayer, own_translucent: bool) -> TileLayer {
match t {
TileLayer::Layer { layer, opaque, .. } => TileLayer::Layer {
layer,
translucent: own_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
}
}
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
@@ -2574,6 +2576,38 @@ mod quarter_tests {
);
}
#[test]
fn a_tile_draws_in_its_faces_pass() {
// Conquest's grass blade tile: a few partly clear texels, over an opaque face.
let blade = TileLayer::Layer {
layer: 5,
translucent: true,
opaque: false,
};
let face = |translucent| TileLayer::Layer {
layer: 1,
translucent,
opaque: !translucent,
};
let cut = TileLayer::Layer {
layer: 5,
translucent: false,
opaque: false,
};
assert_eq!(in_face_pass(blade, face(false), false), cut);
// Over stained glass the tile blends, as the face does.
let blend = TileLayer::Layer {
layer: 5,
translucent: true,
opaque: false,
};
assert_eq!(in_face_pass(blade, face(true), true), blend);
assert_eq!(
in_face_pass(TileLayer::Default, face(false), false),
face(false)
);
}
#[test]
fn overlays_take_the_cubes_own_uvs() {
use horizon_assets::model::Direction as D;
+41 -19
View File
@@ -4693,20 +4693,19 @@ impl ParticleEngine {
.collect()
}
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`), turned by
/// `Camera.rotation()`: `rotationYXZ(PI - yRot, -xRot, 0)`.
pub fn render(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
base: Quat,
partial: f32,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
yaw,
pitch,
base,
partial,
0.0,
None,
@@ -4721,8 +4720,7 @@ impl ParticleEngine {
pub fn render_culled(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
base: Quat,
partial: f32,
pixel_focal_length: f32,
culling_enabled: bool,
@@ -4732,13 +4730,12 @@ impl ParticleEngine {
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render(camera, yaw, pitch, partial, light_at, out);
self.render(camera, base, partial, light_at, out);
return;
}
self.render_inner::<true>(
camera,
yaw,
pitch,
base,
partial,
pixel_focal_length,
frustum,
@@ -4751,8 +4748,7 @@ impl ParticleEngine {
fn render_inner<const CULL: bool>(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
base: Quat,
partial: f32,
pixel_focal_length: f32,
frustum: Option<&crate::gfx::Frustum>,
@@ -4760,9 +4756,6 @@ impl ParticleEngine {
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
* Quat::from_rotation_x(-pitch.to_radians());
let mut light_cache = ParticleLightCache::default();
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
additive.clear();
@@ -5173,12 +5166,11 @@ mod tests {
let light_at = |_: [i32; 3]| 0xf0;
let mut regular = Vec::new();
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &light_at, &mut regular);
let mut disabled = Vec::new();
engine.render_culled(
DVec3::ZERO,
0.0,
0.0,
Quat::IDENTITY,
0.5,
900.0,
false,
@@ -5207,6 +5199,36 @@ mod tests {
assert_eq!(attributes(&disabled), attributes(&regular));
}
#[test]
fn quads_face_the_rotation_they_are_given() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
engine.atlas.insert(sprite.clone(), (rect, false));
let mut random = LegacyRandom::new(7);
let mut particle =
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
particle.sprite = Sprite::Named(sprite);
engine.particles.push(particle);
let draw = |rotation: Quat| {
let mut out = Vec::new();
engine.render(DVec3::ZERO, rotation, 0.5, &|_| 0xf0, &mut out);
out.iter().map(|v| v.pos).collect::<Vec<_>>()
};
// A bed's view turns the quads as the player's own yaw would not.
let ahead = draw(Quat::IDENTITY);
let turned = draw(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
assert_eq!(ahead.len(), 4);
assert_ne!(ahead, turned);
}
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
@@ -5600,7 +5622,7 @@ mod tests {
particle.sprite = Sprite::Index(0);
engine.particles.push(particle);
let mut vertices = Vec::new();
engine.render(DVec3::ZERO, 0.0, 0.0, 0.0, &|_| 0, &mut vertices);
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.0, &|_| 0, &mut vertices);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
@@ -143,21 +143,39 @@ mod tests {
p.size = 0.1;
e.particles.push(p.clone());
let mut vertices = Vec::new();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
));
e.particles[0].set_visible(false);
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
assert!(vertices.is_empty());
assert_eq!(e.particles[0].age, 2);
e.pending.push(copy);
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
assert!(e.pending.is_empty());
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
assert!(vertices.is_empty());
e.tick_particle(&mut p, &level);
assert!(p.removed);
@@ -1971,7 +1971,7 @@ mod tests {
// The camera 5 blocks south, at the particle's height.
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
let mut out = Vec::new();
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
engine.render(camera, glam::Quat::IDENTITY, 0.0, &|_| 0xF0, &mut out);
assert_eq!(out.len(), 4);
for v in &out {
// The texture's top row (v 0) under the centre, its bottom row over it.
@@ -2023,8 +2023,7 @@ mod tests {
let mut out = Vec::new();
engine.render(
DVec3::new(1.0, 64.0, 6.0),
0.0,
0.0,
glam::Quat::IDENTITY,
0.0,
&|_| 0xF0,
&mut out,
@@ -2138,8 +2137,7 @@ mod tests {
let mut out = Vec::new();
engine.render(
DVec3::new(1.0, 70.0, 1.0),
0.0,
0.0,
glam::Quat::IDENTITY,
0.0,
&|_| 0xF0,
&mut out,
@@ -580,6 +580,15 @@ pub fn button(
"widget/button"
};
sprite(out, sprite_id, x, y, w, h);
// `AbstractWidget.WithInactiveMessage.defaultInactiveMessage`: an inactive
// widget's message is -6250336 (0xFFA0A0A0), not the active white.
let inactive;
let label = if active {
label
} else {
inactive = label.clone().with_color(0xFFA0_A0A0);
&inactive
};
scrolling_string_over_contents(out, m, label, x, y, w, h, 2, now_ms);
}
/// `Tooltip.splitTooltip` of a widget's tooltip, shown under the mouse.
@@ -1150,6 +1159,33 @@ mod button_label_tests {
button(&mut out, m, text, 10, 20, w, 20, true, false, now_ms);
out
}
/// `WithInactiveMessage`: a disabled button's message is grey, not white.
#[test]
fn an_inactive_buttons_label_is_grey() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut out = Vec::new();
button(
&mut out,
&m,
&Text::literal("Go"),
10,
20,
100,
20,
false,
false,
0,
);
let color = out
.iter()
.find_map(|d| match d {
Draw::Text { text, .. } => text.spans.iter().find_map(|s| s.style.color),
_ => None,
})
.expect("the label is drawn with its inactive style");
assert_eq!(color & 0xFF_FFFF, 0xA0_A0A0);
}
fn scissor_of(draws: &[Draw]) -> Option<[i32; 4]> {
draws.iter().find_map(|d| match d {
Draw::Scissor { x0, y0, x1, y1 } => Some([*x0, *y0, *x1, *y1]),
@@ -506,8 +506,8 @@ pub struct Streaming {
decoration_order: DecorationOrder,
order_area: Option<((i32, i32), i32)>,
/// What the queued jobs' priorities were computed from (`requeue`): the
/// centre, the band, and the extra areas.
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
/// centre, the band, the extra areas, and the forced chunks.
queue_basis: forced_ring::Basis,
finishing: FxHashSet<(i32, i32)>,
decorates: bool,
/// `ChunkGenerator::decorates_beside_final`.
@@ -1071,6 +1071,7 @@ pub struct WorkerContext {
}
mod bounded;
mod forced_cover;
mod forced_ring;
mod relight_now;
use bounded::nearest_first;
use forced_cover::ForcedCover;
@@ -1354,11 +1355,9 @@ impl Streaming {
/// render distance 32 nothing was decorated until every proto chunk had
/// been generated.
fn requeue(&mut self) {
let basis = (self.center, self.band);
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
if !forced_ring::basis_changed(self) {
return;
}
self.queue_basis = (basis, self.extra.clone());
let mut queue = self.shared.queue.lock().unwrap();
rank_again(&mut queue, |x, z| self.distance(x, z));
}
@@ -2862,7 +2861,7 @@ impl Ranges<'_> {
_ => own,
}
}
fn distance(&self, x: i32, z: i32) -> i64 {
fn player_distance(&self, x: i32, z: i32) -> i64 {
let d = |c: (i32, i32)| {
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
dx * dx + dz * dz
@@ -0,0 +1,186 @@
//! How a forced chunk's ring is ranked (#13175).
use super::{Band, FORCED_TERRAIN_RING, Ranges, Streaming};
use std::collections::HashSet;
type Chunks = HashSet<(i32, i32)>;
/// What queued jobs were ranked from (`Streaming::requeue`): the centre, the
/// band, the extra areas, and the forced chunks.
#[derive(Default)]
pub(super) struct Basis {
player: ((i32, i32), Option<Band>),
extra: Vec<((i32, i32), i32)>,
forced: (Chunks, Chunks),
}
/// Whether the queued jobs' ranking is out of date; it is brought up to date.
pub(super) fn basis_changed(s: &mut Streaming) -> bool {
let b = &mut s.queue_basis;
let same = b.player == (s.center, s.band)
&& b.extra == s.extra
&& b.forced.0 == s.forced
&& b.forced.1 == s.mod_forced;
if !same {
b.player = (s.center, s.band);
b.extra.clone_from(&s.extra);
b.forced.0.clone_from(&s.forced);
b.forced.1.clone_from(&s.mod_forced);
}
!same
}
impl Ranges<'_> {
/// `player_distance`, or the nearer one to a forced chunk whose ring reaches
/// (x, z): see `forced_distance`.
pub(super) fn distance(&self, x: i32, z: i32) -> i64 {
self.player_distance(x, z).min(self.forced_distance(x, z))
}
/// Distance (squared) to the nearest forced chunk whose ring reaches
/// (x, z), or `i64::MAX`. A forced chunk's ring is loaded with its ticket
/// (levels 32 to 35 around the FORCED ticket's 31), so the 3x3 it needs to
/// tick is ranked around it, not by how far it lies from the player: vanilla
/// ticks the chunk on the first tick (`ChunkMap.prepareTickingChunk`).
pub(super) fn forced_distance(&self, x: i32, z: i32) -> i64 {
const SIDE: usize = (2 * FORCED_TERRAIN_RING + 1) as usize;
let held = self.forced.len() + self.mod_forced.len();
if held == 0 {
return i64::MAX;
}
let d = |f: (i32, i32)| {
let (dx, dz) = (i64::from(x - f.0), i64::from(z - f.1));
dx * dx + dz * dz
};
if held <= SIDE * SIDE {
return self
.forced_chunks()
.filter(|f| (x - f.0).abs().max((z - f.1).abs()) <= FORCED_TERRAIN_RING)
.map(|&f| d(f))
.min()
.unwrap_or(i64::MAX);
}
// Many forced chunks: look at the columns around (x, z) instead.
(-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING)
.flat_map(|dz| (-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING).map(move |dx| (dx, dz)))
.map(|(dx, dz)| (x + dx, z + dz))
.filter(|f| self.is_forced(f.0, f.1))
.map(d)
.min()
.unwrap_or(i64::MAX)
}
}
#[cfg(test)]
mod tests {
use super::super::{GenerationInputs, GenerationScan, NO_CHUNKS};
use super::*;
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::world::World;
/// A forced chunk's ring is loaded with its ticket, so it ranks around
/// the forced chunk and not by how far it lies from the player. Vanilla
/// then ticks the chunk on the first tick after a reload
/// (`ChunkMap.prepareTickingChunk`: its 3x3 is full at once); ranked from
/// the player at the origin, a ring 4096 blocks out arrived west/north
/// first, over tens of ticks.
#[test]
fn a_forced_chunks_ring_is_ranked_around_it() {
let ranges = |forced| Ranges {
center: (0, 0),
radius: 2,
extra: &[],
forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let forced: HashSet<(i32, i32)> = [(256, 256)].into();
let r = ranges(&forced);
// Squared distance to the forced chunk, by hand: (255, 254) is
// one west and two north of it.
assert_eq!(r.priority(256, 256), 0);
assert_eq!(r.priority(255, 254), 1 + 4);
assert_eq!(r.priority(260, 252), 16 + 16);
// Past the fourth ring (level 36 and up) the player's distance rules,
// as does the whole map without a forced chunk.
assert_eq!(r.priority(261, 256), 261 * 261 + 256 * 256);
let none = HashSet::new();
assert_eq!(ranges(&none).priority(255, 254), 255 * 255 + 254 * 254);
// Many forced chunks (the neighbour lookups) agree with few.
let many: HashSet<(i32, i32)> = (0..100).map(|i| (256 + 20 * i, 256)).collect();
assert_eq!(ranges(&many).priority(255, 254), 5);
assert_eq!(ranges(&many).priority(276, 258), 4);
// The generation plan fetches the 3x3 the chunk ticks with first.
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
let (generating, finishing) = (FxHashSet::default(), FxHashSet::default());
let protos = FxHashMap::default();
let input = GenerationInputs {
ranges: ranges(&forced),
world: &world,
generating: &generating,
protos: &protos,
finishing: &finishing,
decorates: true,
remote: false,
};
let candidates = GenerationScan::default()
.candidates(input, usize::MAX)
.expect("a scan");
let ring: Vec<(i32, i32)> = candidates
.iter()
.map(|&(_, x, z)| (x, z))
.filter(|&(x, z)| (x - 256).abs().max((z - 256).abs()) <= FORCED_TERRAIN_RING)
.collect();
assert_eq!(ring.len(), 81);
assert_eq!(ring[0], (256, 256));
let d = |(x, z): (i32, i32)| (x - 256).pow(2) + (z - 256).pow(2);
for pair in ring.windows(2) {
assert!(
d(pair[0]) <= d(pair[1]),
"{:?} before {:?}",
pair[0],
pair[1]
);
}
let first_nine: HashSet<(i32, i32)> = ring[..9].iter().copied().collect();
let expected: HashSet<(i32, i32)> = (-1..=1)
.flat_map(|dx| (-1..=1).map(move |dz| (256 + dx, 256 + dz)))
.collect();
assert_eq!(first_nine, expected);
}
/// Jobs queued before a chunk was forced follow it once it is
/// (`Streaming::requeue` ranks them again).
#[test]
fn queued_jobs_follow_a_forced_chunk() {
use super::super::{Job, Queued, rank_again};
use std::collections::BinaryHeap;
let job = |x, z, order| Queued {
priority: 0,
at: Some((x, z)),
bias: 0,
order,
job: Job::Generate(x, z),
};
let first_after = |forced: &HashSet<(i32, i32)>| {
let ranges = Ranges {
center: (0, 0),
radius: 2,
extra: &[],
forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
// (255, 254) lies nearer the origin than (258, 256), the
// forced chunk's neighbour two chunks east.
let mut queue = BinaryHeap::from(vec![job(255, 254, 1), job(258, 256, 2)]);
rank_again(&mut queue, |x, z| ranges.priority(x, z));
queue.pop().and_then(|q| q.at)
};
assert_eq!(first_after(&HashSet::new()), Some((255, 254)));
assert_eq!(first_after(&[(256, 256)].into()), Some((258, 256)));
}
}
+55
View File
@@ -1104,6 +1104,34 @@ pub fn tooltip_menu(
);
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
}
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: a keyboard-focused widget's tooltip.
pub fn tooltip_focused(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
if lines.is_empty() {
return;
}
let (w, h) = tooltip_size(atlas, lines);
let (x, y) = focused_tooltip_position(
(batch.width as i32, batch.height as i32),
rect.map(|v| v as i32),
(w.ceil() as i32, h.ceil() as i32),
);
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
}
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: below, or above when it does not fit.
fn focused_tooltip_position(screen: (i32, i32), rect: [i32; 4], size: (i32, i32)) -> (i32, i32) {
let [left, top, width, height] = rect;
let mut x = left + 3;
let mut y = top + height + 4;
if y + size.1 + 3 > screen.1 {
y = top - size.1 - 4;
}
if x + size.0 > screen.0 {
x = (left + width - size.0 - 3).max(4);
}
(x, y)
}
/// A widget's tooltip while the controller's focus is on it (Horizon's: vanilla has no gamepad
/// input, and the hidden pointer mid-widget would put it over the widget).
pub fn tooltip_under(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
@@ -2614,6 +2642,33 @@ mod tests {
assert!(!delete_words(&mut v, 1));
assert_eq!(v, "stone");
}
#[test]
fn a_keyboard_focused_tooltip_uses_vanillas_widget_edges() {
let screen = (320, 240);
assert_eq!(
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 40)),
(10, 184)
);
// Exact fit below; one pixel taller goes above.
assert_eq!(
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 53)),
(10, 184)
);
assert_eq!(
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 54)),
(10, 102)
);
// Right overflow aligns against the widget's right edge, not the screen's.
assert_eq!(
focused_tooltip_position(screen, [250, 100, 60, 20], (170, 40)),
(137, 124)
);
assert_eq!(
focused_tooltip_position((120, 240), [7, 160, 98, 20], (170, 40)),
(4, 184)
);
}
#[test]
fn a_pad_focused_tooltip_hangs_from_its_widgets_bottom_left() {
let screen = (427.0, 240.0);
@@ -319,6 +319,12 @@ impl World {
pub fn bottom_y(&self) -> i32 {
self.layout().min_y()
}
/// `Entity.checkBelowWorld`'s floor, `Level.getMinY() - 64`: the level's own
/// lowest Y (0 in the Nether and the End), not the chunks' widened column.
/// An entity below it is out of the world.
pub fn below_world_y(&self) -> f64 {
f64::from(self.shape.min_y - 64)
}
/// One above the highest block Y the level's chunks hold.
pub fn top_y(&self) -> i32 {
self.layout().max_y()
+1 -1
View File
@@ -862,7 +862,7 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
self.st.fall_distance *= 0.5;
}
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
if self.position().y < self.scene.world.below_world_y() {
self.st.removed = true;
}
self.st.first_tick = false;
+173 -9
View File
@@ -3,6 +3,7 @@
//! 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};
@@ -30,6 +31,8 @@ pub struct Banners {
dyes: HashMap<Arc<str>, Arc<str>>,
/// Item -> `provides_banner_patterns`, in tag order.
provided: HashMap<Arc<str>, Vec<Arc<str>>>,
/// Banner-pattern tag id -> its members, for a component that names a tag.
pattern_tags: HashMap<String, Vec<Arc<str>>>,
}
fn read_tag(root: &Path, name: &str, depth: u32) -> Vec<Arc<str>> {
@@ -98,6 +101,31 @@ impl Banners {
});
}
banners.no_item_required = read_tag(root, "no_item_required", 0);
// Every tag, `pattern_item/globe` included, for a component naming one.
let tags = root.join("data/minecraft/tags/banner_pattern");
let mut dirs = vec![tags.clone()];
while let Some(dir) = dirs.pop() {
for path in fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
{
if path.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|e| e == "json")
&& let Some(name) = path
.strip_prefix(&tags)
.ok()
.and_then(|p| p.with_extension("").to_str().map(|s| s.replace('\\', "/")))
{
let members = read_tag(root, &name, 0);
banners
.pattern_tags
.insert(format!("minecraft:{name}"), members);
}
}
}
let owned_defaults;
let defaults = match defaults {
Some(defaults) => Some(defaults),
@@ -159,27 +187,88 @@ impl Banners {
}
/// `LoomMenu.isDyeItem`: the dye colour of a loom dye.
pub fn loom_dye(&self, items: &Items, stack: &ItemStack) -> Option<Arc<str>> {
let tagged = items
if !items
.item_tags
.get("minecraft:loom_dyes")
.is_some_and(|t| t.contains(&*stack.item));
tagged
.then(|| self.dyes.get(&stack.item).cloned())
.flatten()
.is_some_and(|t| t.contains(&*stack.item))
{
return None;
}
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:dye").is_some()) {
return None;
}
if let Some(Tag::String(color)) = data.and_then(|d| d.other("minecraft:dye")) {
return Some(Arc::from(
color.to_string_lossy().trim_start_matches("minecraft:"),
));
}
self.dyes.get(&stack.item).cloned()
}
/// `LoomMenu.isPatternItem`.
/// `LoomMenu.isPatternItem`: the item tag and a present
/// `provides_banner_patterns` component (the stack's, else the item's).
pub fn is_pattern_item(&self, items: &Items, stack: &ItemStack) -> bool {
items
if !items
.item_tags
.get("minecraft:loom_patterns")
.is_some_and(|t| t.contains(&*stack.item))
&& self.provided.contains_key(&stack.item)
{
return false;
}
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
return false;
}
data.is_some_and(|d| d.other("minecraft:provides_banner_patterns").is_some())
|| self.provided.contains_key(&stack.item)
}
/// `LoomMenu.getSelectablePatterns`.
pub fn selectable(&self, pattern_item: Option<&ItemStack>) -> Vec<Arc<str>> {
match pattern_item {
None => self.no_item_required.clone(),
Some(stack) => self.provided.get(&stack.item).cloned().unwrap_or_default(),
Some(stack) => {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
return Vec::new();
}
match data.and_then(|d| d.other("minecraft:provides_banner_patterns")) {
Some(tag) => self.provides(tag),
None => self.provided.get(&stack.item).cloned().unwrap_or_default(),
}
}
}
}
/// A `provides_banner_patterns` value: a pattern id, a tag, or a list of
/// either (`HolderSet`'s saved forms).
fn provides(&self, tag: &Tag) -> Vec<Arc<str>> {
match tag {
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
Tag::List(values) => values
.iter()
.flat_map(|value| match value {
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
}
}
fn provides_one(&self, value: &str) -> Vec<Arc<str>> {
// `Identifier.parse`: an id or tag without a namespace is `minecraft`'s.
let namespaced = |id: &str| {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
};
match value.strip_prefix('#') {
Some(tag) => self
.pattern_tags
.get(&namespaced(tag))
.cloned()
.unwrap_or_default(),
None => vec![Arc::from(namespaced(value))],
}
}
}
@@ -249,6 +338,81 @@ mod tests {
assert_eq!(&*b.selectable(Some(&item))[0], "minecraft:flower");
}
#[test]
fn prepared_dye_component() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
let b = Banners::load(&root);
let mut dye = ItemStack::new("minecraft:white_dye", 1);
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "white");
dye.data_mut()
.set_other("minecraft:dye", Some(Tag::String("red".into())));
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "red");
dye.data_mut().set_other(
"!minecraft:dye",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(b.loom_dye(&items, &dye).is_none());
}
#[test]
fn prepared_pattern_component_presence() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
let b = Banners::load(&root);
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
assert!(b.is_pattern_item(&items, &pattern));
pattern.data_mut().set_other(
"!minecraft:provides_banner_patterns",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(!b.is_pattern_item(&items, &pattern));
}
#[test]
fn prepared_pattern_component_selection() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let b = Banners::load(&root);
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:globe");
pattern.data_mut().set_other(
"minecraft:provides_banner_patterns",
Some(Tag::List(vec![Tag::String("minecraft:creeper".into())])),
);
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:creeper");
// One id without its namespace, or a tag, as `HolderSet`'s codec reads them.
pattern.data_mut().set_other(
"minecraft:provides_banner_patterns",
Some(Tag::String("creeper".into())),
);
assert_eq!(
b.selectable(Some(&pattern)),
[Arc::from("minecraft:creeper")]
);
pattern.data_mut().set_other(
"minecraft:provides_banner_patterns",
Some(Tag::String("#pattern_item/globe".into())),
);
assert_eq!(b.selectable(Some(&pattern)), [Arc::from("minecraft:globe")]);
pattern.data_mut().set_other(
"!minecraft:provides_banner_patterns",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(b.selectable(Some(&pattern)).is_empty());
}
#[test]
fn banner_colour_comes_from_the_item() {
assert_eq!(
banner_color("minecraft:light_gray_banner"),
+38 -2
View File
@@ -18,13 +18,28 @@ impl Traveller {
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
/// through it on the next tick.
pub fn enter(&mut self, gateway: BlockPos) {
self.state.set_inside(gateway, ENTITY_DELAY);
self.enter_with_delay(gateway, ENTITY_DELAY);
}
/// Enter with the traveller's `Entity.getDimensionChangingDelay`.
pub fn enter_with_delay(&mut self, gateway: BlockPos, delay: i32) {
self.state.set_inside(gateway, delay);
}
/// `Entity.handlePortal`: the entity goes through the gateway it entered
/// (the End gateway has no transition time), or stays where it is.
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
self.state.handle(allowed, 0, ENTITY_DELAY)
self.travel_with_delay(allowed, ENTITY_DELAY)
}
/// Travel with the entity-specific portal cooldown.
pub fn travel_with_delay(&mut self, allowed: bool, delay: i32) -> Option<BlockPos> {
self.state.handle(allowed, 0, delay)
}
/// `Entity.load` updates its counter without replacing the portal process.
pub fn set_cooldown(&mut self, cooldown: i32) {
self.state.cooldown = cooldown;
}
/// Whether the entity's travel is over: it has no portal process left and
@@ -171,6 +186,27 @@ mod tests {
assert!(!traveller.is_done());
}
#[test]
fn entity_specific_cooldowns_refresh_then_expire_outside_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
for delay in [2, 10, 300] {
let mut traveller = Traveller::default();
traveller.enter_with_delay(gateway, delay);
assert_eq!(traveller.travel_with_delay(true, delay), Some(gateway));
assert_eq!(traveller.cooldown(), delay);
// Continuing contact refreshes the counter, without another trip.
traveller.enter_with_delay(gateway, delay);
assert_eq!(traveller.travel_with_delay(true, delay), None);
assert_eq!(traveller.cooldown(), delay - 1);
for _ in 1..delay {
assert_eq!(traveller.travel_with_delay(true, delay), None);
}
assert!(traveller.is_done());
traveller.enter_with_delay(gateway, delay);
assert_eq!(traveller.travel_with_delay(true, delay), Some(gateway));
}
}
#[test]
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
+1 -1
View File
@@ -948,7 +948,7 @@ impl ItemEntity {
if self.age != i16::MIN as i32 {
self.age += 1;
}
self.age < 6000 && self.position.y > -128.0
self.age < 6000 && self.position.y >= f64::from(world.shape.min_y - 64)
}
/// `Entity.makeStuckInBlock` as `WebBlock.entityInside` and
/// `PowderSnowBlock.entityInside` call it for an item (which, unlike a
+49 -7
View File
@@ -22,6 +22,8 @@ use horizon_world::{
registry::{BlockRegistry, BlockStateId},
};
use std::{collections::HashMap, sync::Arc};
mod boat_load;
pub use boat_load::boat_from_nbt;
mod projectile_fields;
use projectile_fields::{load_projectile_entity_fields, put_projectile_entity_fields};
mod saved_attributes;
@@ -3472,7 +3474,7 @@ pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
power: i32::from(number(tag.get("ExplosionPower")).unwrap_or(1) as i8),
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
"minecraft:dragon_fireball" => ProjectileKind::DragonFireball,
@@ -3702,9 +3704,6 @@ pub fn vehicle_to_nbt(
}
.tag(raw)
}
pub fn boat_from_nbt(tag: &Tag) -> Option<(Vec3, Vec3, f32)> {
Some((position(tag)?, motion(tag), rotation(tag)[0]))
}
/// `Entity.load`'s `OnGround` (`getBooleanOr`: `byteValue() != 0`) and
/// `fall_distance` (`getDoubleOr`: any number's `doubleValue`), defaulting to
/// false and 0.
@@ -4435,6 +4434,30 @@ mod tests {
assert_eq!(load(Tag::Short(7), Tag::Short(19)), (7, 19));
}
/// `Axolotl.Variant.BY_ID` is `OutOfBoundsStrategy.ZERO`: an id outside
/// 0..=4 is lucy (0). `Parrot.Variant.BY_ID` is `CLAMP`: it is the nearest
/// end.
#[test]
fn an_unknown_axolotl_variant_is_lucy_and_a_parrots_is_clamped() {
let variant = |species, id: &str, saved: i32| {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = id.into();
ty.species = species;
let types = [(id.into(), kind)].into();
let raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("Variant", Tag::Int(saved)),
]);
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant
};
let axolotl = |saved| variant(crate::mobs::Species::Axolotl, "minecraft:axolotl", saved);
let parrot = |saved| variant(crate::mobs::Species::Parrot, "minecraft:parrot", saved);
assert_eq!([0, 3, 4, 5, 7, -1].map(axolotl), [0, 3, 4, 0, 0, 0]);
assert_eq!([0, 3, 4, 5, 7, -1].map(parrot), [0, 3, 4, 4, 4, 0]);
}
/// A mount keeps its body armour and a helmet (say
/// `/item replace entity <horse> armor.head with minecraft:iron_helmet`)
/// in their own slots through a save: vanilla holds both (`equipment.body`
@@ -5916,8 +5939,7 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
/// medium salmon or index 0; a missing bucket flag is false.
fn read_variant(m: &mut Mob, tag: &Tag) {
use crate::mobs::Species as S;
// AbstractFish/Axolotl.readAdditionalSaveData: false when absent.
// Tadpole.setFromBucket ignores the input.
// AbstractFish/Axolotl: false when absent. Tadpole.setFromBucket ignores the input.
if matches!(
m.species(),
S::Cod | S::Salmon | S::Pufferfish | S::TropicalFish | S::Axolotl
@@ -5926,7 +5948,8 @@ fn read_variant(m: &mut Mob, tag: &Tag) {
}
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
match m.species() {
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8, // CLAMP
S::Axolotl => m.extra.variant = ((int("Variant") as u32).min(5) % 5) as u8, // ZERO: lucy
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
S::Rabbit => {
m.extra.variant = match int("RabbitType") {
@@ -9433,6 +9456,25 @@ mod stand_fall_save_tests {
}
}
/// `LargeFireball.readAdditionalSaveData` reads `ExplosionPower` with
/// `getByteOr`: an int tag is narrowed (`(byte)(value & 255)`), so 300 is
/// 44 and 200 is -56.
#[test]
fn a_fireball_narrows_its_explosion_power_to_a_byte() {
for (saved, expected) in [(Tag::Int(300), 44), (Tag::Int(200), -56), (Tag::Byte(4), 4)] {
let input = Tag::compound([
("id", string("minecraft:fireball")),
("Pos", doubles(&[4.5, 205.0, 4.5])),
("ExplosionPower", saved),
]);
let fireball = fireball_from_nbt(&input, None).unwrap();
assert!(
matches!(fireball.kind, ProjectileKind::LargeFireball { power } if power == expected),
"{expected}"
);
}
}
#[test]
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
@@ -0,0 +1,47 @@
use super::{loaded_motion, position, rotation};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::Tag;
/// `Entity.load` for a record with a saved position: clamp coordinates before
/// rebuilding the box. Reject invalid positions; motion over 10 resets.
pub fn boat_from_nbt(tag: &Tag) -> Option<(Vec3, Vec3, f32)> {
let p = position(tag)?;
let p = Vec3::new(
p.x.clamp(-30_000_512.0, 30_000_512.0),
p.y.clamp(-20_000_000.0, 20_000_000.0),
p.z.clamp(-30_000_512.0, 30_000_512.0),
);
p.is_finite()
.then(|| (p, loaded_motion(tag), rotation(tag)[0]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_clamps_each_position_axis_and_only_motion_over_ten() {
let doubles = |v: [f64; 3]| Tag::List(v.map(|v| Tag::Double(v.to_bits())).to_vec());
for (raw, expected) in [
(
[40_000_000.0, 30_000_000.0, -40_000_000.0],
[30_000_512.0, 20_000_000.0, -30_000_512.0],
),
(
[-40_000_000.0, -30_000_000.0, 40_000_000.0],
[-30_000_512.0, -20_000_000.0, 30_000_512.0],
),
([1.25, -64.0, -3.5], [1.25, -64.0, -3.5]),
] {
let tag = Tag::compound([
("Pos", doubles(raw)),
("Motion", doubles([10.0, -10.0, 10.00001])),
]);
let (p, m, _) = boat_from_nbt(&tag).unwrap();
assert_eq!(p.components(), expected);
assert_eq!(m, Vec3::new(10.0, -10.0, 0.0));
}
let tag = Tag::compound([("Pos", doubles([0.0, f64::NAN, 0.0]))]);
assert!(boat_from_nbt(&tag).is_none());
}
}
+7 -9
View File
@@ -5088,11 +5088,11 @@ impl Game<'_> {
if removed && !creative {
let held = self.held().cloned();
self.mine_block_with_held(&facts.name, facts.hardness);
let harvest = !facts.requires_tool
|| self
.res
.items()
.correct_for_drops(held.as_ref(), &facts.name);
let harvest = self.res.items().has_correct_tool_for_drops(
held.as_ref(),
facts.requires_tool,
&facts.name,
);
if harvest {
// Block.playerDestroy: the mined statistic, the exhaustion and the drops.
self.award_stat("mined", &facts.name, 1);
@@ -7206,8 +7206,7 @@ impl Game<'_> {
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
self.particles.render(
camera,
self.yaw,
self.pitch,
self.particle_rotation(),
alpha as f32,
&|b| world.light(b[0], b[1], b[2]),
&mut out,
@@ -7226,8 +7225,7 @@ impl Game<'_> {
let mut particle_culling = self.particle_occlusion_context(camera);
self.particles.render_culled(
camera,
self.yaw,
self.pitch,
self.particle_rotation(),
alpha as f32,
pixel_focal_length,
true,
+115 -25
View File
@@ -15,6 +15,7 @@ use horizon_client::{
mesher::Vertex,
mobs::render::{DYE, DYE_NAMES},
test_instance_block::Status,
text::Text,
};
use horizon_protocol::packets::play::{self, Serverbound as S};
@@ -30,12 +31,15 @@ pub(super) const EFFECTS: [(&str, i32); 6] = [
("strength", 3),
("regeneration", 4),
];
const PAYMENT: [&str; 5] = [
"minecraft:iron_ingot",
"minecraft:gold_ingot",
"minecraft:emerald",
"minecraft:diamond",
"minecraft:netherite_ingot",
/// `BeaconScreen.extractBackground`'s payment row in drawing order, with each
/// item's x: netherite, emerald, diamond, gold, iron. Not by value: the
/// emerald is left of the diamond.
const PAYMENT_ROW: [(&str, f32); 5] = [
("minecraft:netherite_ingot", 20.0),
("minecraft:emerald", 41.0),
("minecraft:diamond", 63.0),
("minecraft:gold_ingot", 86.0),
("minecraft:iron_ingot", 108.0),
];
/// `BeaconMenu.PaymentSlot` and the player inventory, relative to the 176 origin.
pub(super) fn slot_position(i: usize) -> Option<(f32, f32)> {
@@ -73,6 +77,43 @@ fn upgrade_button() -> (f32, f32) {
fn inside(p: (f32, f32), x: f32, y: f32, w: f32, h: f32) -> bool {
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
}
/// `BeaconPowerButton.setEffect`'s tooltip: the effect's description, and for
/// `BeaconUpgradePowerButton` (`createEffectDescription`) " II" after it.
fn effect_tooltip(e: usize, upgraded: bool) -> Text {
let effect = Text::translate(&format!("effect.minecraft.{}", EFFECTS[e].0), &[]);
if upgraded {
effect.append(" II")
} else {
effect
}
}
/// `BeaconScreen`: every button's tooltip whichever one the cursor is over
/// (`BeaconPowerButton`'s its effect, `BeaconSpriteScreenButton`'s its label),
/// hovered whether or not it is active.
fn hovered_tooltip(
local: (f32, f32),
primary: Option<usize>,
buttons: &[(usize, f32, f32, bool)],
) -> Option<Text> {
for &(e, x, y, _) in buttons {
if inside(local, x, y, 22.0, 22.0) {
return Some(effect_tooltip(e, false));
}
}
if let Some(p) = primary {
let (x, y) = upgrade_button();
if inside(local, x, y, 22.0, 22.0) {
return Some(effect_tooltip(p, true));
}
}
if inside(local, 164.0, 107.0, 22.0, 22.0) {
return Some(Text::translate("gui.done", &[]));
}
if inside(local, 190.0, 107.0, 22.0, 22.0) {
return Some(Text::translate("gui.cancel", &[]));
}
None
}
fn effect_id(name: &str) -> Option<&'static str> {
let n = name.strip_prefix("minecraft:").unwrap_or(name);
EFFECTS.iter().map(|(e, _)| *e).find(|e| *e == n)
@@ -502,12 +543,12 @@ impl Game<'_> {
false,
);
}
for (i, item) in PAYMENT.iter().rev().enumerate() {
let x = [20.0, 41.0, 63.0, 86.0, 108.0][i];
for (item, x) in PAYMENT_ROW {
self.draw_item(batch, &ItemStack::new(item, 1), bx + x, by + 109.0);
}
let atlas = &self.gui.atlas;
let mut tooltip = None;
let buttons = buttons();
let tooltip = hovered_tooltip(local, sel[0], &buttons);
let button =
|batch: &mut HudBatch, x: f32, y: f32, active: bool, selected: bool, icon: &str| {
let hover = inside(local, x, y, 22.0, 22.0);
@@ -535,9 +576,8 @@ impl Game<'_> {
18.0,
u32::MAX,
);
hover
};
for (e, x, y, primary) in buttons() {
for (e, x, y, primary) in buttons {
let tier = EFFECTS[e].1;
let active = if primary {
tier <= levels && tier < 4
@@ -550,16 +590,12 @@ impl Game<'_> {
sel[1] == Some(e)
};
let icon = format!("mob_effect/{}", EFFECTS[e].0);
if button(batch, x, y, active, selected, &icon) {
tooltip = Some(format!("effect.minecraft.{}", EFFECTS[e].0));
}
button(batch, x, y, active, selected, &icon);
}
if let Some(p) = sel[0] {
let (x, y) = upgrade_button();
let icon = format!("mob_effect/{}", EFFECTS[p].0);
if button(batch, x, y, levels >= 4, sel[1] == Some(p), &icon) {
tooltip = Some(format!("effect.minecraft.{}", EFFECTS[p].0));
}
button(batch, x, y, levels >= 4, sel[1] == Some(p), &icon);
}
button(
batch,
@@ -597,14 +633,8 @@ impl Game<'_> {
}
} else if let Some(stack) = stack_tip {
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(key) = tooltip {
let text = self.translate(&key, &key);
self.draw_tooltip_lines(
batch,
&[(horizon_client::text::Text::literal(text), u32::MAX)],
self.cursor.0,
self.cursor.1,
);
} else if let Some(text) = tooltip {
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
}
}
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
@@ -707,6 +737,66 @@ fn effect_box_hits(p: [i32; 3], range: f64, height: f64, lo: [f64; 3], hi: [f64;
#[cfg(test)]
mod tests {
use super::*;
/// `BeaconScreen.extractBackground`: netherite 20, emerald 41, diamond
/// 41+22, gold 42+44, iron 42+66.
#[test]
fn the_payment_row_reads_netherite_emerald_diamond_gold_iron() {
assert_eq!(
PAYMENT_ROW,
[
("minecraft:netherite_ingot", 20.0),
("minecraft:emerald", 41.0),
("minecraft:diamond", 63.0),
("minecraft:gold_ingot", 86.0),
("minecraft:iron_ingot", 108.0),
]
);
}
/// `BeaconUpgradePowerButton.createEffectDescription` appends " II"; the
/// primary buttons' description is the bare effect.
#[test]
fn only_the_upgrade_button_names_the_second_level() {
for e in 0..EFFECTS.len() {
let primary = effect_tooltip(e, false).plain();
let upgrade = effect_tooltip(e, true).plain();
assert!(!primary.is_empty());
assert!(!primary.ends_with(" II"));
assert_eq!(upgrade, format!("{primary} II"));
}
}
/// `BeaconScreenButton.setTooltip`: every button has one while hovered,
/// and the sprite buttons' is their label whether or not they are active
/// (the tick is disabled without a payment and a chosen power).
#[test]
fn every_hovered_beacon_button_shows_its_tooltip() {
let at = |(x, y): (f32, f32)| (x + 11.0, y + 11.0);
let name =
|e: usize| horizon_client::lang::get(&format!("effect.minecraft.{}", EFFECTS[e].0));
let buttons = buttons();
for &(e, x, y, _) in &buttons {
let tip = hovered_tooltip(at((x, y)), None, &buttons).unwrap_or_default();
assert_eq!(tip.plain(), name(e), "button {e}");
}
let upgrade = at(upgrade_button());
assert!(hovered_tooltip(upgrade, None, &buttons).is_none());
assert_eq!(
hovered_tooltip(upgrade, Some(0), &buttons).unwrap().plain(),
format!("{} II", name(0))
);
assert_eq!(
hovered_tooltip(at((164.0, 107.0)), None, &buttons)
.unwrap_or_default()
.plain(),
horizon_client::lang::get("gui.done")
);
assert_eq!(
hovered_tooltip(at((190.0, 107.0)), Some(0), &buttons)
.unwrap_or_default()
.plain(),
horizon_client::lang::get("gui.cancel")
);
assert!(hovered_tooltip((0.0, 0.0), Some(0), &buttons).is_none());
}
#[test]
fn only_active_beacons_have_beams() {
let mut b = Beacon::default();
@@ -131,6 +131,10 @@ pub(super) trait Editor {
fn accepts(&self, _edit: usize, _current: &str, _cursor: usize, _c: char) -> bool {
true
}
/// The box's tooltip (`EditBox.setTooltip`), if it has one.
fn edit_tooltip(&self, _edit: usize) -> Option<Text> {
None
}
/// Whether Enter is Done (a screen with its own `keyPressed`).
fn enter_confirms(&self) -> bool {
true
@@ -171,6 +175,41 @@ impl BlockEditScreen {
}
}
/// `WidgetTooltipHolder.refreshTooltipForNextRenderPass`: hovering or keyboard focus shows it.
fn edit_tooltip(
atlas: &horizon_client::hud::HudAtlas,
editor: &dyn Editor,
items: &[Item],
cursor: (f32, f32),
focus: Option<usize>,
keyboard: bool,
virtual_pointer: bool,
) -> Option<(Vec<Text>, [f32; 4], bool)> {
let mut hovered = None;
for item in items {
if let Item::Edit { index, x, y, w } = item {
let hover = !virtual_pointer && ui::hovered(cursor, *x, *y, *w, 20.0);
let focused = keyboard && focus == Some(*index);
if (hover || focused)
&& let Some(tip) = editor.edit_tooltip(*index)
{
// `Tooltip.splitTooltip` uses 170 GUI pixels, including translated text.
let tooltip = (
atlas.font.split(&tip, 170.0),
[*x, *y, *w, 20.0],
focused && !hover,
);
// `GuiGraphicsExtractor.setTooltipForNextFrame`: the focused widget wins.
if focused {
return Some(tooltip);
}
hovered = Some(tooltip);
}
}
}
hovered
}
impl Game<'_> {
/// `LocalPlayer.open*Block`: the edit screen for the block entity at
/// `pos`.
@@ -243,6 +282,11 @@ impl Game<'_> {
match outcome {
Outcome::Stay => {}
Outcome::Send(packet) => {
// The integrated server is this game: vanilla's packet handler
// applies the edit there, so a local game applies it here.
if self.remote.is_none() {
self.apply_local_editor_packet(&packet);
}
self.send(packet);
self.close_block_editor();
}
@@ -256,6 +300,14 @@ impl Game<'_> {
}
}
/// The server side of an editor's packet, for the game that is its own
/// server: `handleSetJigsawBlock` and its siblings run on the server.
fn apply_local_editor_packet(&mut self, packet: &S) {
if let S::SetJigsawBlock(p) = packet {
self.apply_set_jigsaw_block(p);
}
}
pub(super) fn draw_block_editor(&mut self, batch: &mut HudBatch) {
let cursor = self.cursor;
let Some(items) = self.with_block_editor(|s, e| {
@@ -354,6 +406,29 @@ impl Game<'_> {
}
}
}
// The focus navigator's pointer is hidden, not an actual mouse hover.
let navigation_focus = self.pad_focus_rect();
let focus = navigation_focus.map_or(screen.focus, |rect| {
items.iter().find_map(|item| match item {
Item::Edit { index, x, y, w } if [*x, *y, *w, 20.0] == rect => Some(*index),
_ => None,
})
});
if let Some((lines, rect, focused)) = edit_tooltip(
atlas,
screen.editor.as_ref(),
&items,
cursor,
focus,
self.narration.input_type().is_keyboard(),
navigation_focus.is_some(),
) {
if focused {
ui::tooltip_focused(batch, atlas, &lines, rect);
} else {
ui::tooltip_menu(batch, atlas, &lines, cursor, rect);
}
}
self.block_editor = Some(screen);
}
@@ -643,6 +718,107 @@ pub(super) fn on_off(on: bool) -> Text {
mod tests {
use super::*;
struct TooltipEditor;
impl Editor for TooltipEditor {
fn edits(&mut self) -> &mut [EditBox] {
&mut []
}
fn items(&self, _entity: &BlockEntity, _w: f32, _h: f32) -> Vec<Item> {
Vec::new()
}
fn press(
&mut self,
_entity: &mut BlockEntity,
_pos: Pos,
_id: u8,
_shift: bool,
_alt: bool,
) -> Outcome {
Outcome::Stay
}
fn done(&mut self, _entity: &mut BlockEntity, _pos: Pos) -> Outcome {
Outcome::Stay
}
fn cancel(&mut self, _entity: &mut BlockEntity) {}
fn edit_tooltip(&self, edit: usize) -> Option<Text> {
match edit {
4 => Some(Text::literal(
"A long explanation of the selection priority that must wrap without losing any of its words.",
)),
5 => Some(Text::literal("Placement priority")),
_ => None,
}
}
}
#[test]
fn edit_tooltips_follow_hover_keyboard_and_hidden_navigation_pointer() {
let atlas = horizon_client::hud::HudAtlas::boot();
let items = [
Item::Edit {
index: 0,
x: 7.0,
y: 20.0,
w: 300.0,
},
Item::Edit {
index: 5,
x: 110.0,
y: 160.0,
w: 98.0,
},
Item::Edit {
index: 4,
x: 7.0,
y: 160.0,
w: 98.0,
},
];
let get = |cursor, focus, keyboard, virtual_pointer| {
edit_tooltip(
&atlas,
&TooltipEditor,
&items,
cursor,
focus,
keyboard,
virtual_pointer,
)
};
let (lines, rect, focused) = get((20.0, 165.0), None, false, false).unwrap();
assert_eq!(rect, [7.0, 160.0, 98.0, 20.0]);
assert!(!focused);
assert!(lines.len() > 1);
assert!(
lines
.iter()
.all(|line| atlas.font.width_text(line) <= 170.0)
);
assert_eq!(
lines.iter().map(Text::plain).collect::<Vec<_>>().join(" "),
"A long explanation of the selection priority that must wrap without losing any of its words."
);
assert!(get((0.0, 0.0), Some(4), true, false).unwrap().2);
assert!(get((0.0, 0.0), Some(4), false, false).is_none());
// A real hover uses the menu positioner, even when the field has keyboard focus.
assert!(!get((20.0, 165.0), Some(4), true, false).unwrap().2);
// Keyboard/controller navigation puts a hidden pointer in the field's center.
assert!(get((56.0, 170.0), Some(4), true, true).unwrap().2);
assert!(get((56.0, 170.0), None, true, true).is_none());
assert!(get((20.0, 25.0), Some(0), true, false).is_none());
assert!(get((105.0, 180.0), None, false, false).is_none());
// A keyboard-focused field overrides a different field's real hover.
let (focused_lines, focused_rect, focused) =
get((120.0, 165.0), Some(4), true, false).unwrap();
assert_eq!(
focused_lines.iter().map(Text::plain).collect::<Vec<_>>(),
lines.iter().map(Text::plain).collect::<Vec<_>>()
);
assert_eq!(focused_rect, rect);
assert!(focused);
}
#[test]
fn name_characters_follow_screen_is_valid_character_for_name() {
assert!(valid_character_for_name("", 'a', 0));
@@ -667,4 +843,46 @@ mod tests {
assert_eq!(cycle(&[1, 2, 3], 9, 1), 1);
assert_eq!(cycle(&[1, 2, 3], 9, -1), 2);
}
/// A single-player jigsaw edit is applied by the game that is its own
/// server, as `handleSetJigsawBlock` does there, and marks the chunk.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_local_jigsaw_edit_applies_and_marks_the_chunk() {
let mut game = crate::game::native_machines::tests::server_game();
let pos = (2, 4, 3);
let state = game
.pack
.assets
.registry
.default_state("minecraft:jigsaw")
.unwrap();
game.world.set(pos.0, pos.1, pos.2, state).unwrap();
game.world
.block_entities
.insert(pos, BlockEntity::JigsawBlock(Box::default()));
game.world.modified.clear();
let packet = S::SetJigsawBlock(horizon_protocol::packets::play::SetJigsawBlock {
pos: [2, 4, 3],
name: "minecraft:test".into(),
target: "minecraft:test".into(),
pool: "minecraft:empty".into(),
final_state: "minecraft:air".into(),
joint: "aligned".into(),
selection_priority: 1,
placement_priority: 2,
});
game.block_editor_outcome(Outcome::Send(packet));
let Some(BlockEntity::JigsawBlock(b)) = game.world.block_entities.get(&pos) else {
panic!("no jigsaw");
};
assert_eq!(&*b.name, "minecraft:test", "the edit landed");
assert_eq!((b.selection_priority, b.placement_priority), (1, 2));
assert!(
game.world.modified.contains(&(0, 0)),
"the edit left the chunk unmarked"
);
}
}
@@ -576,6 +576,10 @@ impl Game<'_> {
let full = count == b.max_passengers();
let delta = b.delta_rotation;
let boat_box = b.bounds();
let ridden = horizon_client::motion::RiddenBoat {
under_water: b.under_water(),
max_y: boat_box.max_position().y,
};
let seats: Vec<_> = mobs
.iter()
.enumerate()
@@ -608,6 +612,7 @@ impl Game<'_> {
for (j, offset, at, previous, facing) in seats {
let m = &mut self.mobs[j];
m.seated(id, offset, boat_box);
m.fluids.boat = Some((id, ridden));
m.position = at;
m.previous = previous;
m.velocity = Vec3::ZERO;
+19 -13
View File
@@ -565,7 +565,10 @@ impl Game<'_> {
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
return None;
}
let above = self.res.facts().get(self.world.block(x, y + 1, z));
let above = self
.res
.facts()
.get(self.world.block(x, y.wrapping_add(1), z));
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
let height = if above.fluid == f.fluid {
1.0
@@ -597,7 +600,7 @@ impl Game<'_> {
return y as f32 + height;
}
}
(y1 + 1) as f32
(i64::from(y1) + 1) as f32
}
fn boat_status(&self, b: &mut Boat) -> Status {
let bb = b.bounds();
@@ -660,12 +663,12 @@ impl Game<'_> {
);
let (g_lo, g_hi) = (ground.min_position(), ground.max_position());
let (x0, x1, y0, y1, z0, z1) = (
g_lo.x.floor() as i32 - 1,
g_hi.x.ceil() as i32 + 1,
g_lo.y.floor() as i32 - 1,
g_hi.y.ceil() as i32 + 1,
g_lo.z.floor() as i32 - 1,
g_hi.z.ceil() as i32 + 1,
(g_lo.x.floor() - 1.0) as i32,
(g_hi.x.ceil() + 1.0) as i32,
(g_lo.y.floor() - 1.0) as i32,
(g_hi.y.ceil() + 1.0) as i32,
(g_lo.z.floor() - 1.0) as i32,
(g_hi.z.ceil() + 1.0) as i32,
);
let slab = VoxelShape::from_box(ground).unwrap_or_else(|_| VoxelShape::empty());
let mut scene = SceneCollisions {
@@ -740,7 +743,7 @@ impl Game<'_> {
if moved_y < 0.0 {
let below = self.res.facts().get(self.world.block(
b.position.x.floor() as i32,
b.position.y.floor() as i32 - 1,
(b.position.y.floor() as i32).wrapping_sub(1),
b.position.z.floor() as i32,
));
if below.fluid != Fluid::Water {
@@ -980,7 +983,7 @@ impl Game<'_> {
if driven.contains(&id) {
self.boats[i].wear_off_hurt();
}
if self.boat_fire_tick(i) {
if self.boat_fire_tick(i) || self.boat_below_world(i) {
break 'boat;
}
}
@@ -1099,9 +1102,12 @@ impl Game<'_> {
));
let (lo, hi) = (b.min_position(), b.max_position());
let mut columns = Vec::new();
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
// Entity.checkInsideBlocks floors both ends of the deflated box.
// Narrowing ceil(max) before subtracting one wraps at i32::MIN,
// turning a tiny box into a scan of the entire integer Y domain.
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if let Some(column) = bubble_column_at(
&self.world,
self.pack.assets.registry,
@@ -107,4 +107,42 @@ mod tests {
.sum();
assert_eq!(spilled, 3);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn saved_deep_end_boat_loads_then_is_discarded_and_extreme_scans_terminate() {
use super::super::Boat;
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::Tag;
let mut g = crate::game::native_machines::tests::server_game();
g.world.shape.min_y = 0;
let doubles = |v: &[f64]| Tag::List(v.iter().map(|d| Tag::Double(d.to_bits())).collect());
let tag = Tag::compound([
(
"Pos",
doubles(&[-77.75688596047263, -2146769274.8915758, -55.6734678353711]),
),
("Motion", doubles(&[-2e-323, -13104.999899947774, -2e-323])),
]);
g.load_boat(tag, None, "minecraft:oak_boat");
assert_eq!(g.boats.len(), 1);
assert_eq!(g.boats[0].position.y, -20_000_000.0);
assert_eq!(g.boats[0].velocity, Vec3::new(-2e-323, 0.0, -2e-323));
g.player = Some(PlayerMotion::new(g.motion, Vec3::new(-77.75, 64.0, -55.67)).unwrap());
g.world
.chunks
.insert((-5, -4), std::sync::Arc::new(Chunk::empty(-5, -4)));
g.update_chunk_tickets();
assert!(g.entity_ticking_area()(-78, -56));
g.tick_boats();
assert!(g.boats.is_empty());
for y in [f64::from(i32::MIN) - 1.0, f64::from(i32::MAX) + 1.0] {
g.boats
.push(Boat::new(2, "oak".into(), Vec3::new(0.5, y, 0.5), 0.0));
assert!(g.boat_bubble_columns(0).is_empty());
assert_eq!(g.boat_ground_friction(g.boats[0].bounds()), 0.0);
g.boats.clear();
}
}
}
@@ -109,6 +109,7 @@ impl Game<'_> {
return;
};
let file = multiplayer::servers_file(&saves());
let _guard = servers::lock();
let (mut list, mut hidden) = servers::load_all(&file);
let mut found = false;
for entry in list.iter_mut().filter(|s| &s.ip == address) {
+74 -21
View File
@@ -37,6 +37,14 @@ struct Entry {
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
direct: Option<Text>,
}
struct PendingAttacker {
damage_type: String,
attacker: (u32, Text),
item: Option<Text>,
living: bool,
credit: bool,
}
/// What is stuck in a player's body: `LivingEntity.DATA_ARROW_COUNT_ID` and
/// `DATA_STINGER_COUNT_ID` with the timers that pull them out
/// (`removeArrowTime`, `removeStingerTime`). Kept with the combat record,
@@ -87,7 +95,7 @@ pub(super) struct CombatTracker {
in_combat: bool,
/// Attackers of damage dealt this tick, claimed by the matching
/// `RecordedDamage` event.
pending: Vec<(String, (u32, Text), Option<Text>)>,
pending: Vec<PendingAttacker>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// The lengths of `pending` and `pending_direct` before the first
@@ -107,9 +115,15 @@ pub(super) struct CombatTracker {
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
/// its entity id and display name, the blast's causing entity.
pub(super) blast_owner: Option<(u32, Text)>,
/// `Some(None)` is an explicitly ownerless crystal/cart DamageSource.
pub(super) explicit_explosion_cause: Option<Option<super::explosion::ExplosionCause>>,
pub(super) explicit_explosion_direct: Option<Text>,
/// The guest behind `blast_owner`, when that player is a LAN guest; the
/// host's own blast leaves it None.
pub(super) blast_guest: Option<u32>,
/// `blast_owner` is a player (`getIndirectSourceEntity() instanceof
/// Player`), not a mob whose UUID the TNT's `owner` names.
pub(super) blast_owner_player: bool,
pub(super) projectile_attacker: Option<(u32, Text)>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
@@ -319,9 +333,32 @@ impl Game<'_> {
item: Option<Text>,
) {
self.combat.mark();
self.combat
.pending
.push((damage_type.to_owned(), (id, name), item));
self.combat.pending.push(PendingAttacker {
damage_type: damage_type.to_owned(),
attacker: (id, name),
item,
living: true,
credit: true,
});
}
/// A non-living or already removed cause remains in the damage source,
/// but cannot resolve as an EntityReference for kill credit.
pub(super) fn note_uncredited_attacker(
&mut self,
damage_type: &str,
id: u32,
name: Text,
item: Option<Text>,
living: bool,
) {
self.combat.mark();
self.combat.pending.push(PendingAttacker {
damage_type: damage_type.to_owned(),
attacker: (id, name),
item,
living,
credit: false,
});
}
/// The `DamageSource.directEntity` of the next `damage_type` damage when
/// nothing caused it (a splash potion nobody threw): the death message
@@ -358,14 +395,20 @@ impl Game<'_> {
}
_ => continue,
};
let (attacker, item) = self
let (attacker, item, living, credit) = self
.combat
.pending
.iter()
.position(|(t, _, _)| *t == name)
.position(|p| p.damage_type == name)
.map(|i| {
let (_, attacker, item) = self.combat.pending.remove(i);
(Some(attacker), item)
let PendingAttacker {
attacker,
item,
living,
credit,
..
} = self.combat.pending.remove(i);
(Some(attacker), item, living, credit)
})
.unwrap_or_default();
let direct = match attacker {
@@ -380,8 +423,10 @@ impl Game<'_> {
let (fall_location, fall_distance) = self.fall_location();
let now = self.world.game_time;
if let Some(a) = &attacker {
self.combat.last_hurt_by = Some((a.clone(), now));
if !self.combat.in_combat {
if living {
self.combat.last_hurt_by = credit.then(|| (a.clone(), now));
}
if living && !self.combat.in_combat {
self.combat.in_combat = true;
}
}
@@ -480,7 +525,7 @@ impl Game<'_> {
}
}
/// The display component of a custom-named held stack.
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
pub(super) fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
let mut stack = m.extra.main_hand_stack()?.clone();
if !has_custom_name(&stack) {
@@ -500,7 +545,7 @@ impl Game<'_> {
/// `main_hand_custom_name` of a player who is not in place: the guest
/// `conn` between turns, or none for the host while a guest acts (their
/// hotbar slot is kept by the swap).
fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
pub(super) fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
if player == self.lan_acting {
return self.main_hand_custom_name();
}
@@ -895,7 +940,7 @@ mod held_item_tests {
}
#[cfg(test)]
mod noted_tests {
use super::CombatTracker;
use super::{CombatTracker, PendingAttacker};
use horizon_client::text::Text;
/// A canceled hit forgets what was noted for it, also before its hurt
@@ -905,11 +950,13 @@ mod noted_tests {
let mut c = CombatTracker::default();
let arrow = |c: &mut CombatTracker, id| {
c.mark();
c.pending.push((
"minecraft:arrow".into(),
(id, Text::literal("skeleton")),
None,
));
c.pending.push(PendingAttacker {
damage_type: "minecraft:arrow".into(),
attacker: (id, Text::literal("skeleton")),
item: None,
living: true,
credit: true,
});
};
arrow(&mut c, 1);
c.forget_noted(false);
@@ -918,7 +965,10 @@ mod noted_tests {
c.pending_direct
.push(("minecraft:arrow".into(), Text::literal("arrow")));
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
assert_eq!(
c.pending.iter().map(|p| p.attacker.0).collect::<Vec<_>>(),
[1]
);
assert!(c.pending_direct.is_empty());
// A hit noted and landed, then one noted and canceled: the landed
// one keeps its attacker.
@@ -927,7 +977,10 @@ mod noted_tests {
c.forget_noted(false);
arrow(&mut c, 4);
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
assert_eq!(
c.pending.iter().map(|p| p.attacker.0).collect::<Vec<_>>(),
[1, 3]
);
}
}
#[cfg(test)]
@@ -1010,7 +1063,7 @@ mod guest_hit_tests {
game.combat.pending.clear();
game.guest_mob_event(5, hit);
let noted: Vec<u32> = (game.combat.pending.iter())
.map(|(_, (id, _), _)| *id)
.map(|pending| pending.attacker.0)
.collect();
assert_eq!(noted, [5]);
assert_eq!(game.combat.melee_mob, None, "the hit used it up");
+3 -18
View File
@@ -13,6 +13,7 @@ pub(in crate::game) mod items;
mod misc;
mod place;
mod players;
mod position_args;
pub(super) mod pregen;
mod profiles;
mod raid;
@@ -517,22 +518,6 @@ impl Game<'_> {
pub(super) fn position_arg(&self, ctx: &Context, name: &str) -> Option<[f64; 3]> {
self.coords(ctx, name).map(|c| self.resolve_coords(&c))
}
/// BlockPosArgument.getLoadedBlockPos.
pub(super) fn block_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
let c = self
.coords(ctx, name)
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
// BlockPos.containing(getPosition)
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
}
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
return Err(err("argument.pos.outofworld", &[]));
}
Ok(p)
}
fn int(ctx: &Context, name: &str) -> Option<i32> {
match ctx.get(name) {
Some(Arg::Int(v)) => Some(*v),
@@ -3794,7 +3779,7 @@ impl Game<'_> {
vec![Target::Player]
};
let pos = if ctx.get("pos").is_some() {
self.block_pos_arg(ctx, "pos")?
self.spawnable_pos_arg(ctx, "pos")?
} else {
let p = self.source_position();
[
@@ -3855,7 +3840,7 @@ impl Game<'_> {
}
fn cmd_setworldspawn(&mut self, ctx: &Context) -> R<i32> {
let pos = if ctx.get("pos").is_some() {
self.block_pos_arg(ctx, "pos")?
self.spawnable_pos_arg(ctx, "pos")?
} else {
let p = self.source_position();
[
@@ -0,0 +1,36 @@
//! Block position argument validation for command execution.
use super::{Context, Game, R, err};
impl Game<'_> {
/// BlockPosArgument.getLoadedBlockPos.
pub(in crate::game) fn block_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
let c = self
.coords(ctx, name)
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
// BlockPos.containing(getPosition)
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
}
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
return Err(err("argument.pos.outofworld", &[]));
}
Ok(p)
}
/// BlockPosArgument.getSpawnablePos: no loaded-chunk or build-height check,
/// only `Level.isInSpawnableBounds` (upper bounds exclusive).
pub(in crate::game) fn spawnable_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
let c = self
.coords(ctx, name)
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
if !(-30_000_000..30_000_000).contains(&p[0])
|| !(-30_000_000..30_000_000).contains(&p[2])
|| !(-20_000_000..20_000_000).contains(&p[1])
{
return Err(err("argument.pos.outofbounds", &[]));
}
Ok(p)
}
}
+24 -33
View File
@@ -7,6 +7,7 @@ mod mob_geometry;
mod mob_group;
mod mob_islands;
mod prep;
mod projectile_targets;
mod projectile_world;
mod pufferfish_player;
mod spawn_eggs;
@@ -796,7 +797,7 @@ impl Game<'_> {
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
let info = me.res.items().get(&stack.item)?;
if info.equip_slot.is_none()
|| !info.equip_damage_on_hurt
|| !horizon_client::item_sync::damage_on_hurt(stack, info.equip_damage_on_hurt)
|| info.max_damage == 0
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|| (fire && me.res.items().resists_fire(stack, name))
@@ -4919,38 +4920,7 @@ impl Game<'_> {
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
}
}
/// The boats, minecarts and primed TNT a projectile can strike
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`).
pub(super) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
let boats = self.boats.iter().map(|b| (Who::Mob(b.id), b.bounds()));
let carts = self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()));
let tnts = self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds()));
boats.chain(carts).chain(tnts).collect()
}
/// What a projectile can hit: the mobs, the player, the dragon's parts and
/// the End crystals (under reserved ids, game/dragon.rs).
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (Who::Mob(m.id), m.bounds()))
.collect();
if let Some(p) = self.player_view()
&& p.alive
{
targets.push((Who::Player, p.bounds));
}
targets.extend(
self.special_targets()
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
// Boats, minecarts and primed TNT are pickable for every projectile,
// so every kind shares this one list.
targets.extend(self.pickable_object_targets());
targets
}
/// `Level.isRainingAt` for arrows.
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
move |p: BlockPos| self.raining_at(p)
}
@@ -5201,6 +5171,24 @@ impl Game<'_> {
return Ok(());
}
}
if p.arrow.is_some()
&& let Hit::Entity {
who: Who::Mob(id), ..
} = hit
&& self.item_frames.iter().any(|f| f.id == id)
{
// AbstractArrow.onHitEntity on an item frame: spent when the
// frame took the hit, turned back when a fixed one refused it.
let by_player = p.owner == Some(Who::Player);
let hurt = self.projectile_hurts_frame(id, by_player, by_player && self.creative);
let outcome = if hurt {
EntityHit::Hurt
} else {
EntityHit::Refused
};
self.finish_arrow_hit(p.clone(), outcome);
return Ok(());
}
if p.arrow.is_some()
&& let Hit::Entity {
who: Who::Mob(id), ..
@@ -8832,6 +8820,9 @@ mod chorus_projectile_drop_tests {
}
}
#[cfg(test)]
#[path = "frame_projectile_tests.rs"]
mod frame_projectile_tests;
#[cfg(test)]
#[path = "loaded_arrow_shot_tests.rs"]
mod loaded_arrow_shot_tests;
@@ -0,0 +1,57 @@
//! What a projectile can strike: the mobs, the player, the dragon's parts and
//! End crystals, and the non-living entities that are pickable (boats,
//! minecarts, item frames).
use super::super::Game;
use horizon_client::mobs::Who;
use horizon_core::geometry::{Aabb, Vec3};
impl Game<'_> {
/// The boats, minecarts, primed TNT and item frames a projectile can strike
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`,
/// `BlockAttachedEntity.isPickable`).
pub(in crate::game) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.boats
.iter()
.map(|b| (Who::Mob(b.id), b.bounds()))
.collect();
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
targets.extend(self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds())));
targets.extend(self.item_frames.iter().map(|f| {
let (lo, hi) = f.bounds();
(
Who::Mob(f.id),
Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
),
)
}));
targets
}
/// What a projectile can hit: the mobs, the player, the dragon's parts and
/// the End crystals (under reserved ids, game/dragon.rs), and the vehicles
/// and item frames.
pub(in crate::game) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
let mut targets: Vec<(Who, Aabb)> = self
.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (Who::Mob(m.id), m.bounds()))
.collect();
if let Some(p) = self.player_view()
&& p.alive
{
targets.push((Who::Player, p.bounds));
}
targets.extend(
self.special_targets()
.into_iter()
.map(|(id, b)| (Who::Mob(id), b)),
);
// Boats, minecarts, primed TNT and item frames are pickable for every
// projectile, so every kind shares this one list.
targets.extend(self.pickable_object_targets());
targets
}
}
@@ -3,6 +3,9 @@
use super::*;
impl ProjectileWorld for ProjWorld<'_, '_> {
fn below_world_y(&self) -> f64 {
self.w.world.below_world_y()
}
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
@@ -68,17 +68,13 @@ fn block_break_speed_factor(value: Option<f64>) -> f32 {
}
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
pub(super) fn modify_destroy_speed(&mut self, mut speed: f32) -> f32 {
if speed > 1.0 {
speed += self
.player
.as_mut()
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
.unwrap_or(0.0) as f32;
}
speed * self.potions.effects.mining_speed_factor()
/// `Attributes.MINING_EFFICIENCY` (efficiency), which `Player.getDestroySpeed`
/// adds to a tool speed above 1: 0 without a player or attribute.
pub(super) fn mining_efficiency(&mut self) -> f32 {
self.player
.as_mut()
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
.unwrap_or(0.0) as f32
}
/// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor:
/// 1.0 by default, so a missing player or attribute changes nothing.
+32 -3
View File
@@ -1535,8 +1535,15 @@ impl Game<'_> {
if self.restart_portal_cooldown(id) {
continue;
}
self.end.gateways.entry(id).or_default().enter(pos);
let delay = self.entity_portal_delay(id);
self.end
.gateways
.entry(id)
.or_default()
.enter_with_delay(pos, delay);
}
// ThrowableProjectile applies the gateway's block effect before Entity.tick.
self.handle_end_gateway_portals_phase(true);
}
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
@@ -1558,7 +1565,11 @@ impl Game<'_> {
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
// Nobody in a gateway (nearly always): nothing to look up.
self.handle_end_gateway_portals_phase(false);
}
fn handle_end_gateway_portals_phase(&mut self, after_move: bool) {
// Nobody in a gateway: nothing to look up.
if self.remote.is_some() || self.dim != Dim::End || self.end.gateways.is_empty() {
return;
}
@@ -1606,11 +1617,25 @@ impl Game<'_> {
if !ticking {
continue;
}
// Only an entity in a gateway has a state: skip the others before
// the per-id lookups below, which walk the entity lists.
if !self.end.gateways.contains_key(&id) {
continue;
}
let is_pearl = self
.creatures
.projectiles
.iter()
.any(|p| p.id == id && matches!(p.kind, ProjectileKind::EnderPearl));
if is_pearl != after_move {
continue;
}
let delay = self.entity_portal_delay(id);
let Some(state) = self.end.gateways.get_mut(&id) else {
continue;
};
// The End gateway has no transition time (Portal.getPortalTransitionTime).
if let Some(entry) = state.travel(allowed) {
if let Some(entry) = state.travel_with_delay(allowed, delay) {
leaving.push((id, entry));
}
if state.is_done() {
@@ -1939,3 +1964,7 @@ mod tests {
}
}
}
#[cfg(test)]
#[path = "gateway_cooldown_tests.rs"]
mod cooldown_tests;
+397 -34
View File
@@ -6,6 +6,7 @@ use super::{
Game,
entity::{Blast, Entity, EntityHandle, with_entity, with_entity_mut},
};
use horizon_client::{
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
};
@@ -23,6 +24,16 @@ use horizon_world::{
};
use std::{cmp::Ordering, io};
/// The causing entity of an explicit explosion damage source. Minecart ignition
/// keeps this in memory only, just like `MinecartTNT.ignitionSource`.
#[derive(Clone, Debug)]
pub(super) struct ExplosionCause {
pub entity: (u32, Text),
pub player: bool,
pub guest: Option<u32>,
pub species: Option<horizon_client::mobs::Species>,
}
/// `Level.clip` with `ClipContext.Block.COLLIDER`, no fluids and the
/// collision context of the entity whose exposure is measured.
struct ColliderRays<'a, 'r> {
@@ -171,6 +182,10 @@ fn blast_direction(center: [f64; 3], origin: [f64; 3]) -> [f64; 3] {
struct ExplosionHookContext<'a> {
center: [f64; 3],
blocks: &'a [[i32; 3]],
explicit_cause: Option<Option<ExplosionCause>>,
direct_name: Option<Text>,
held_item: Option<Text>,
credit: bool,
}
/// An entity in the blast's reach: how far (as a share of the blast's
@@ -187,12 +202,10 @@ fn hurt_mob(
hurtable: bool,
cause: Option<horizon_client::mobs::Species>,
(amount, source): (f32, horizon_client::mobs::Damage),
) {
) -> bool {
let shielded =
cause.is_some_and(|c| horizon_client::mobs::wither::shielded_from(m.species(), c));
if hurtable && !shielded {
m.damage(amount, source);
}
hurtable && !shielded && m.damage(amount, source)
}
/// A blast's causing entity and its name (`Explosion.getIndirectSourceEntity`):
@@ -228,8 +241,101 @@ pub(super) fn client_float() -> f32 {
}
impl Game<'_> {
/// `blast_cause` for the blast going off now.
pub(super) fn explosion_entity_name(&self, id: u32, kind: &str) -> Text {
self.persist_raw(id)
.and_then(|r| r.get("CustomName"))
.map_or_else(
|| Text::translate(&format!("entity.{}", kind.replace(':', ".")), &[]),
|name| {
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(
name,
))
},
)
}
fn explosion_cause_resolves(&self, cause: &ExplosionCause) -> bool {
if !cause.player {
return self
.mobs
.iter()
.any(|m| m.id == cause.entity.0 && !m.removed);
}
let acting = self
.lan_acting_entity
.or_else(|| self.player.as_ref().map(|p| p.entity_id as u32));
acting == Some(cause.entity.0)
|| self.lan.as_ref().is_some_and(|host| {
host.hub
.guests
.iter()
.any(|g| g.entity_id as u32 == cause.entity.0)
})
}
/// An explicit DamageSource must not inherit an enclosing blast's cause.
pub(super) fn with_explosion_cause<R>(
&mut self,
cause: Option<ExplosionCause>,
direct_source: (u32, Text),
run: impl FnOnce(&mut Self) -> R,
) -> R {
// The DamageSource retains the removed mob's display name. This is
// deliberately not added to exploded_kinds: its EntityReference no
// longer resolves for kill credit, unlike Horizon's early exploders.
if let Some(cause) = &cause
&& cause.species.is_some()
&& !self.mobs.iter().any(|m| m.id == cause.entity.0)
&& !self
.combat
.exploded
.iter()
.any(|(id, _)| *id == cause.entity.0)
{
self.combat.exploded.push(cause.entity.clone());
}
let source = self.combat.explosion_source.take();
let owner = self.combat.explosion_owner.take();
let player = self.combat.blast_owner.take();
let guest = self.combat.blast_guest.take();
let direct = self.combat.explosion_cause.take();
self.combat.explosion_source = Some(direct_source.0);
let outer_direct = self
.combat
.explicit_explosion_direct
.replace(direct_source.1);
if let Some(cause) = &cause {
if cause.player {
self.combat.blast_owner = Some(cause.entity.clone());
self.combat.blast_guest = cause.guest;
} else {
self.combat.explosion_owner = Some(cause.entity.0);
}
}
let outer = self.combat.explicit_explosion_cause.replace(cause);
let result = run(self);
self.combat.explicit_explosion_cause = outer;
self.combat.explosion_source = source;
self.combat.explosion_owner = owner;
self.combat.blast_owner = player;
self.combat.blast_guest = guest;
self.combat.explosion_cause = direct;
self.combat.explicit_explosion_direct = outer_direct;
result
}
/// The blast damage's cause. An explicit crystal/cart DamageSource may
/// differ from the explosion's own indirect source.
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
if let Some(cause) = &self.combat.explicit_explosion_cause {
return cause.as_ref().map(|c| {
let name = (!c.player)
.then(|| self.mob_display_name(c.entity.0))
.flatten()
.unwrap_or_else(|| c.entity.1.clone());
(c.entity.0, name)
});
}
// `Explosion.getIndirectSourceEntity`: a primed TNT's owner names the
// blast before any mob does, which makes the damage type
// `minecraft:player_explosion` (`server_explode` asks this).
@@ -438,7 +544,7 @@ impl Game<'_> {
attacker: Option<&(u32, Text)>,
cause: Option<&horizon_client::advancements::EntityView>,
) -> Option<[f64; 3]> {
let ExplosionHookContext { center, blocks } = *context;
let ExplosionHookContext { center, blocks, .. } = *context;
let diameter = f64::from(radius * 2.0);
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
// piece, so two Blast Protection IV pieces cancel it).
@@ -491,10 +597,39 @@ impl Game<'_> {
let flying = self.player.as_ref().is_some_and(|p| p.flying);
let mut outcome = horizon_client::health::BlockOutcome::default();
let mut pushed = None;
let explicit = context.explicit_cause.as_ref().and_then(|c| c.as_ref());
let living_attacker = explicit.is_none_or(|c| c.player || c.species.is_some());
let living_non_player = context
.explicit_cause
.as_ref()
.map_or(attacker.is_some(), |c| {
c.as_ref().is_some_and(|c| !c.player && c.species.is_some())
});
// The death message names the blast's causing entity. It goes to this
// player's tracker, the host's or a guest's.
if let Some((id, name)) = attacker.filter(|_| !creative && !spectator && hurtable) {
self.note_named_attacker(damage_type, *id, name.clone());
if living_attacker && context.credit {
self.note_player_attacker(
damage_type,
*id,
name.clone(),
context.held_item.clone(),
);
} else {
self.note_uncredited_attacker(
damage_type,
*id,
name.clone(),
context.held_item.clone(),
living_attacker,
);
}
} else if !creative
&& !spectator
&& hurtable
&& let Some(direct) = context.direct_name.clone()
{
self.note_direct_entity(damage_type, direct);
}
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.health_resources.damage.id(damage_type)
@@ -509,7 +644,7 @@ impl Game<'_> {
id,
amount,
blocking,
attacker.is_some(),
living_non_player,
// The blast's causing entity.
attacker.map_or(0, |(id, _)| *id as i32),
)
@@ -617,7 +752,33 @@ impl Game<'_> {
// same for all of them (`onExplosionHit(this.source)`).
let attacker = self.blast_attacker();
let blast_source = self.combat.explosion_cause.clone();
let context = ExplosionHookContext { center, blocks };
let context = ExplosionHookContext {
center,
blocks,
explicit_cause: self.combat.explicit_explosion_cause.clone(),
direct_name: self.combat.explicit_explosion_direct.clone(),
credit: self
.combat
.explicit_explosion_cause
.as_ref()
.is_none_or(|cause| {
cause
.as_ref()
.is_some_and(|cause| self.explosion_cause_resolves(cause))
}),
held_item: self
.combat
.explicit_explosion_cause
.as_ref()
.and_then(|c| c.as_ref())
.and_then(|c| {
if c.player {
self.player_main_hand_custom_name(c.guest)
} else {
self.mob_main_hand_name(c.entity.0)
}
}),
};
let own = self.explosion_hurt_player(
&context,
radius,
@@ -655,10 +816,17 @@ impl Game<'_> {
// Mobs. `WitherBoss.hurtServer`: a wither shrugs off an undead cause.
let cause = self
.combat
.explosion_owner
.or(exploding)
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| m.species());
.explicit_explosion_cause
.as_ref()
.and_then(|c| c.as_ref())
.and_then(|c| c.species)
.or_else(|| {
self.combat
.explosion_owner
.or(exploding)
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| m.species())
});
// `ServerExplosion.hurtEntities`: every entity in reach, on the walk. Each
// answers `ignoreExplosion` itself (`Entity::ignores_explosion`); the rest
// is the same for all, and only what a blast does to a kind differs. The
@@ -667,7 +835,8 @@ impl Game<'_> {
// (as `retain` does), and a destroyed boat or cart shifts its list, so
// `gone` counts how far.
let blast = Blast {
vehicles_ignore: self.combat.blast_owner.is_none()
vehicles_ignore: self.combat.explicit_explosion_cause.is_none()
&& self.combat.blast_owner.is_none()
&& self.blast_attacker().is_some()
&& !self.ui.rules.bool("mob_griefing"),
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
@@ -704,18 +873,53 @@ impl Game<'_> {
kind: horizon_client::mobs::DamageKind::Explosion,
// A projectile's owner (`getIndirectSourceEntity`),
// else the exploding mob itself.
attacker: attacker
attacker: self
.combat
.explicit_explosion_cause
.as_ref()
.map(|&(id, _)| id)
.filter(|&o| o != m.id)
.map(horizon_client::mobs::Who::Mob),
.and_then(|c| c.as_ref())
.filter(|c| c.player)
.map(|_| Who::Player)
.or_else(|| {
attacker
.as_ref()
.map(|&(id, _)| id)
.filter(|&o| o != m.id)
.filter(|_| {
self.combat
.explicit_explosion_cause
.as_ref()
.is_none_or(|c| {
c.as_ref().is_some_and(|c| c.species.is_some())
})
})
.map(horizon_client::mobs::Who::Mob)
}),
from: Some(center),
attacker_species: cause,
};
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`:
// its base is 0 for every mob type, so iron golems and
// ravagers are pushed.
hurt_mob(m, hurtable, cause, (amount, source));
let victim = m.id;
let hurt = hurt_mob(m, hurtable, cause, (amount, source));
if hurt {
if source.attacker == Some(Who::Player) {
let guest = self.combat.blast_guest;
self.last_hurt_by_player.insert(victim, guest);
if let Some(host) = self.lan.as_mut() {
match guest {
Some(conn) => {
host.mob_players.insert(victim, conn);
}
None => {
host.mob_players.remove(&victim);
}
}
}
}
self.resolve_player_responsible(victim, source);
}
// Enderman.hurtServer teleports inside the hurt, before
// the push (the mob order stays as it was).
if let Err(e) = self.settle_hurt_teleports() {
@@ -817,8 +1021,15 @@ impl Game<'_> {
self.explosion_breaks_decorations(center, diameter, interaction);
pushed
}
/// `ServerExplosion.getIndirectSourceEntity`: the causing living entity,
/// including a TNT igniter or the player who broke an end crystal.
fn blast_damage_caused_by_mob(&self) -> bool {
self.combat.explicit_explosion_cause.as_ref().map_or_else(
|| self.blast_attacker().is_some(),
|cause| cause.as_ref().is_some_and(|c| c.species.is_some()),
)
}
/// Decoration kills credit `DamageSource.getEntity`: the explicit blast
/// cause, including a TNT igniter or the player who broke an end crystal.
pub(super) fn explosion_kill_credit(&self) -> Option<super::commands::Target> {
use super::commands::Target;
// A primed TNT's owner (a player) names the blast before any mob does.
@@ -834,10 +1045,10 @@ impl Game<'_> {
}
self.crystal_blast_player()
}
/// `blast_spares_decorations` for the blast going off now: its causing
/// mob (`blast_attacker`) is the source's entity.
/// Decoration hurt checks use the damage source's cause (`instanceof Mob`),
/// unlike vehicles, which use the explosion's indirect source.
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
let by_mob = self.blast_attacker().is_some();
let by_mob = self.blast_damage_caused_by_mob();
blast_spares_decorations(
self.ui.rules.bool("mob_griefing"),
interaction,
@@ -865,7 +1076,7 @@ impl Game<'_> {
interaction: BlockInteraction,
) {
// An armour stand is no `BlockAttachedEntity`: a source in water does not spare it.
let by_mob = self.blast_attacker().is_some();
let by_mob = self.blast_damage_caused_by_mob();
let spares_stands = blast_spares_decorations(
self.ui.rules.bool("mob_griefing"),
interaction,
@@ -961,12 +1172,38 @@ impl Game<'_> {
fire: bool,
source: ExplosionSource,
) -> Result<()> {
let damage_type = super::creatures::damage_type(
horizon_client::mobs::DamageKind::Explosion,
self.blast_attacker().is_some(),
);
self.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
self.server_explode_from_cause(center, radius, interaction, fire, source, None)
}
/// An explicit `DamageSource` is independent of the blast's indirect owner.
/// `Some(None)` is explicitly ownerless; `None` keeps the ordinary source.
pub(super) fn server_explode_from_cause(
&mut self,
center: [f64; 3],
radius: f32,
interaction: BlockInteraction,
fire: bool,
source: ExplosionSource,
cause: Option<Option<ExplosionCause>>,
) -> Result<()> {
let blast = |g: &mut Self| {
let damage_type = super::creatures::damage_type(
horizon_client::mobs::DamageKind::Explosion,
g.blast_attacker().is_some(),
);
g.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
};
match cause {
None => blast(self),
Some(cause) => {
let (id, kind) = source.entity.ok_or_else(|| {
io::Error::other("an explicit explosion cause needs its direct entity")
})?;
let direct = (id, self.explosion_entity_name(id, kind));
self.with_explosion_cause(cause, direct, blast)
}
}
}
/// `server_explode` with the damage type the player takes and a block
/// whose explosion resistance the damage calculator overrides.
pub(super) fn server_explode_as(
@@ -1139,6 +1376,15 @@ impl Game<'_> {
if !is_tnt && name.starts_with("minecraft:infested_") {
self.spawn_infestation(pos);
}
// Its dropExperience is whether the blast's indirect source is a
// player (a primed TNT's owner, not a mob's): ores, sculk and spawners
// sample their experience then, and pop it while blocks drop.
if !is_tnt && self.combat.blast_owner_player {
let xp = self.block_experience(&name);
if xp > 0 && self.ui.rules.bool("block_drops") {
self.spawn_orbs(pos.map(|v| f64::from(v) + 0.5), xp);
}
}
if !is_tnt {
let dynamic = self.pending.dynamic(pos);
// LootContextParams.EXPLOSION_RADIUS under DESTROY_WITH_DECAY: the
@@ -1186,10 +1432,18 @@ impl Game<'_> {
let rot = r.next_double() * f64::from(std::f32::consts::TAU);
let fuse = r.next_int_bound(20).unwrap_or(0) + 10;
// TntBlock.wasExploded hands the new TNT the blast's indirect
// source, so a chain reaction keeps the player who started it.
let (owner, guest) = match &self.combat.blast_owner {
Some(_) => (Some(Who::Player), self.combat.blast_guest),
None => (None, None),
// source, so a chain reaction keeps a primed TNT's owner, player or mob.
let (owner, guest) = match &self.combat.explicit_explosion_cause {
// A crystal/cart's explicit DamageSource does not change
// Explosion.getIndirectSourceEntity, which is null for them.
Some(_) => (None, None),
None => match &self.combat.blast_owner {
Some(_) if self.combat.blast_owner_player => {
(Some(Who::Player), self.combat.blast_guest)
}
Some((id, _)) => (Some(Who::Mob(*id)), None),
None => (None, None),
},
};
self.add_primed_tnt(
BlockPos::new(pos[0], pos[1], pos[2]),
@@ -1795,6 +2049,115 @@ mod tests {
}
}
#[cfg(test)]
mod explicit_source_tests {
use super::*;
use crate::game::native_explosions::NativeBlast;
use horizon_client::{mobs::Mob, motion::PlayerMotion};
#[test]
#[ignore = "requires prepared resources"]
fn explicit_sources_keep_names_without_inventing_kill_credit() {
let mut game = crate::game::native_machines::tests::server_game();
game.options.dedicated = None;
game.creative = false;
game.ui.difficulty = 2;
game.build_registries();
for mode in 0..5 {
let nested = mode == 4;
game.combat = Default::default();
game.mobs.clear();
game.orbs.clear();
game.dropped.clear();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, 8.5)).unwrap());
let kind = game.res.mob_types().get("minecraft:husk").unwrap().clone();
let species = kind.species;
if mode == 2 || nested {
game.mobs
.push(Mob::new(43, kind, Vec3::new(40.0, 64.0, 40.0), 0.0));
}
// An enclosing blast must not leak its owner into an explicit source.
game.combat.explosion_owner = Some(99);
game.combat.exploded.push((99, Text::literal("Outer")));
let cause = (mode != 0).then(|| ExplosionCause {
entity: (43, Text::literal("Igniter")),
player: false,
guest: None,
species: (mode != 1).then_some(species),
});
if nested {
game.options.dedicated = Some(Vec::new());
game.combat.explicit_explosion_cause = Some(cause);
game.combat.explicit_explosion_direct = Some(Text::literal("Outer"));
game.combat.blast_owner = Some((99, Text::literal("Outer")));
game.combat.blast_guest = Some(17);
let center = [8.5, 64.0, 8.5];
assert!(game.native_explode(
"minecraft:overworld",
center,
2.0,
false,
NativeBlast::Keep
));
assert!(matches!(
game.combat.explicit_explosion_cause,
Some(Some(_))
));
assert!(game.combat.explicit_explosion_direct.is_some());
assert_eq!(
game.combat.blast_owner.as_ref().map(|owner| owner.0),
Some(99)
);
assert_eq!(game.combat.blast_guest, Some(17));
} else {
game.server_explode_from_cause(
[8.5, 64.0, 8.5],
2.0,
BlockInteraction::Keep,
false,
ExplosionSource::default()
.with_entity(7, "minecraft:end_crystal")
.with_silence(true),
Some(cause),
)
.unwrap();
}
let events = std::mem::take(&mut game.player.as_mut().unwrap().events);
game.record_combat(&events);
assert_eq!(
game.combat
.last_blow()
.map(|(kind, id)| (kind.to_owned(), id)),
Some((
if mode == 0 || nested {
"minecraft:explosion"
} else {
"minecraft:player_explosion"
}
.into(),
if mode == 0 || nested { 0 } else { 43 }
))
);
assert_eq!(
game.combat.kill_credit(game.world.game_time),
(mode == 2).then_some(43)
);
if mode == 1 || mode == 3 || nested {
let death = game
.combat_death_message_for(Text::literal("Victim"))
.component();
if nested {
assert_eq!(death["with"].as_array().unwrap().len(), 1);
} else {
assert_eq!(death["with"][1]["text"].as_str(), Some("Igniter"));
}
}
assert_eq!(game.combat.explosion_owner, Some(99));
assert_eq!(game.combat.explicit_explosion_cause.is_some(), nested);
}
}
}
#[cfg(test)]
mod shulker_lock_drop_tests {
use super::super::native_machines::tests::server_game;
@@ -201,6 +201,15 @@ impl Rocket {
}
self.life += 1;
}
/// The end of `Projectile.shoot`: yaw and pitch are set straight from the
/// shot's motion (the old rotation follows, as `rotation` is the old one
/// on the next tick).
fn turn_to_shot(&mut self) {
let v = self.velocity;
let horizontal = (v.x * v.x + v.z * v.z).sqrt();
let atan2 = horizon_core::geometry::trig::atan2_degrees;
self.rotation = [atan2(v.x, v.z), atan2(v.y, horizontal)];
}
/// `Projectile.updateRotation`: a fifth of the way from `old` (yaw,
/// pitch) toward its motion's direction, the short way round.
fn update_rotation(&mut self, old: [f32; 2]) {
@@ -485,6 +494,7 @@ impl Game<'_> {
let mut own = random_source::entity();
let mut rocket = self.new_rocket(at, stack, false, &mut own);
rocket.velocity = shoot(&mut own);
rocket.turn_to_shot();
rocket.angled = true;
rocket.owner = owner;
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
@@ -1045,6 +1055,20 @@ mod events_tests;
#[cfg(test)]
mod tests {
/// `Projectile.shoot` turns a shot rocket to its motion at once.
#[test]
fn a_shot_rocket_starts_turned_to_its_motion() {
use horizon_core::geometry::Vec3;
let mut rocket = super::Rocket::remote(1, Vec3::ZERO, Vec3::new(0.0, 0.5, 0.0));
rocket.turn_to_shot();
assert!((rocket.rotation[1] - 90.0).abs() < 1e-3);
let mut rocket = super::Rocket::remote(1, Vec3::ZERO, Vec3::new(1.0, 0.0, 1.0));
rocket.turn_to_shot();
assert!((rocket.rotation[0] - 45.0).abs() < 1e-4);
assert!(rocket.rotation[1].abs() < 1e-4);
}
/// A rocket draws its UUID's two longs first, then the motion's two triangles
/// (four doubles), then `nextInt(6)` and `nextInt(7)` for the lifetime, all from
/// the one random. The expected values come from `java.util.Random` replayed
+7 -2
View File
@@ -24,6 +24,9 @@ use std::{collections::HashMap, sync::Arc};
use super::lan_actions::GuestPlay;
#[cfg(test)]
#[path = "fishing_air_tests.rs"]
mod air_tests;
#[cfg(test)]
#[path = "fishing_block_hit_tests.rs"]
mod block_hit_tests;
@@ -714,8 +717,10 @@ impl Game<'_> {
}
/// getOpenWaterTypeForBlock: 0 above water, 1 inside water, 2 invalid.
fn open_water_type(&self, x: i32, y: i32, z: i32) -> u8 {
let f = self.res.facts().get(self.world.block(x, y, z));
if self.world.is_air(self.world.block(x, y, z)) || &*f.name == "minecraft:lily_pad" {
let state = self.world.block(x, y, z);
let f = self.res.facts().get(state);
// `!state.isAir()`: cave_air and void_air are air too.
if self.res.facts().is_air(state) || &*f.name == "minecraft:lily_pad" {
return 0;
}
if f.fluid == Fluid::Water
@@ -0,0 +1,93 @@
//! `FishingHook.getOpenWaterTypeForBlock` tests `!state.isAir()`, and `isAir()` covers
//! cave_air and void_air: a hook over a source under a cave's air is open water, so its
//! catch can be treasure. (A stone block is not air, so it is not open water either.)
use super::{FISHING_TABLE, fishing_loot};
use horizon_world::loot::LootStack;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn cave_air_above_a_water_source_is_open_water_and_catches_treasure() {
let mut game = super::super::native_machines::tests::server_game();
let (air, cave_air, void_air, water, stone) = {
let registry = &game.pack.assets.registry;
let id = |name: &str| registry.default_state(name).unwrap();
(
id("minecraft:air"),
id("minecraft:cave_air"),
id("minecraft:void_air"),
id("minecraft:water"),
id("minecraft:stone"),
)
};
// The three air blocks read alike: ABOVE_WATER (0), never INVALID (2).
for (name, state) in [("air", air), ("cave_air", cave_air), ("void_air", void_air)] {
game.world.set(5, 63, 5, state).unwrap();
assert_eq!(game.open_water_type(5, 63, 5), 0, "{name} is air");
}
// calculateOpenWater's 5x4x5 area around the hook's block: a source at -1 and the
// cave's air at 0..2, as in a mineshaft room filled with water.
for x in 3..=7 {
for z in 3..=7 {
game.world.set(x, 60, z, water).unwrap();
for y in 61..=63 {
game.world.set(x, y, z, cave_air).unwrap();
}
}
}
assert!(
game.calculate_open_water([5, 61, 5]),
"cave_air above a source is open water"
);
// One block that is not air: the area is not open water.
game.world.set(5, 62, 5, stone).unwrap();
assert!(!game.calculate_open_water([5, 61, 5]));
game.world.set(5, 62, 5, cave_air).unwrap();
// The flag the hook's retrieval reads: `gameplay/fishing`'s treasure entry is
// conditioned on `in_open_water`, so the cave's air decides whether treasure comes.
let loot =
horizon_client::structure_blocks::loot_data(&game.options.resources, &game.pack_files())
.expect("the fishing loot table");
// `gameplay/fishing/treasure`'s items (26.3's table).
let treasure = [
"minecraft:name_tag",
"minecraft:saddle",
"minecraft:bow",
"minecraft:fishing_rod",
"minecraft:book",
"minecraft:nautilus_shell",
];
let catches = |open_water: bool| -> Vec<LootStack> {
(0..400)
.flat_map(|seed| {
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
let mut level_random = horizon_core::random::LegacyRandom::new(seed);
fishing_loot(
&loot,
FISHING_TABLE,
&mut sequences,
&mut level_random,
seed,
0.0,
None,
open_water,
)
})
.collect()
};
let treasure_catches = |items: &[LootStack]| {
items
.iter()
.filter(|item| treasure.contains(&item.item.as_str()))
.count()
};
assert_eq!(
treasure_catches(&catches(false)),
0,
"not open water: no treasure"
);
assert!(
treasure_catches(&catches(true)) > 0,
"open water can catch treasure"
);
}
@@ -0,0 +1,117 @@
//! A projectile that strikes an item frame hurts it (#11383): `ItemFrame.hurtServer`
//! gives the item up, an empty frame breaks, and a fixed one refuses the hit.
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
for command in [
"forceload add 0 0",
"fill 0 63 0 15 63 15 stone",
"fill 4 64 0 4 66 15 stone",
] {
assert!(game.run_command(command), "{command}");
}
game
}
/// An arrow from `(0.5, 64.5, z)` flying east into the wall at x = 4.
fn shoot(game: &mut Game<'_>, z: f64) {
assert!(game.run_command(&format!(
"summon minecraft:arrow 0.5 64.5 {z} {{Motion:[1.0d,0.0d,0.0d]}}"
)));
for _ in 0..10 {
game.tick_projectiles().unwrap();
}
}
fn dropped(game: &Game<'_>, item: &str) -> usize {
game.dropped
.iter()
.filter(|d| &*d.stack.item == item)
.count()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_arrow_knocks_the_item_out_then_breaks_the_frame() {
let mut game = game();
// On the wall's west face (Facing 4: west).
assert!(game.run_command(
"summon minecraft:item_frame 3.5 64.5 6.5 {Facing:4b,Item:{id:\"minecraft:diamond\",count:1}}"
));
assert_eq!(game.item_frames.len(), 1);
shoot(&mut game, 6.5);
assert_eq!(game.item_frames.len(), 1, "the first hit leaves the frame");
assert!(game.item_frames[0].item.is_none(), "the item came out");
assert_eq!(dropped(&game, "minecraft:diamond"), 1);
// The empty frame breaks and drops itself.
shoot(&mut game, 6.5);
assert!(game.item_frames.is_empty());
assert_eq!(dropped(&game, "minecraft:item_frame"), 1);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_fixed_frame_refuses_the_arrow() {
let mut game = game();
assert!(game.run_command(
"summon minecraft:item_frame 3.5 64.5 9.5 {Facing:4b,Fixed:1b,Item:{id:\"minecraft:diamond\",count:1}}"
));
shoot(&mut game, 9.5);
assert_eq!(game.item_frames.len(), 1);
assert!(
game.item_frames[0].item.is_some(),
"a fixed frame keeps its item"
);
assert_eq!(dropped(&game, "minecraft:diamond"), 0);
// The frame took the arrow's hit and refused it, so the arrow turned back
// (`AbstractArrow.onHitEntity`'s deflection) instead of reaching the wall.
assert!(
game.creatures
.projectiles
.iter()
.all(|p| p.position.x < 3.9),
"the arrow went through the frame"
);
}
+51 -10
View File
@@ -349,12 +349,45 @@ impl Game<'_> {
}
return true;
}
self.hit_frame(i, true, self.creative);
true
}
/// `ItemFrame.hurtServer` from a projectile (`AbstractArrow.onHitEntity`,
/// `Snowball`, `ThrownEgg`, `ThrownEnderpearl`, `SmallFireball`...): the
/// damage source's entity is the projectile's owner. A fixed frame only
/// gives way to a creative player's shot (`canHurtWhenFixed`), and then
/// breaks whole (`BlockAttachedEntity.hurtServer`). `creative`: that player
/// has infinite materials. False when the frame refused the hit.
pub(super) fn projectile_hurts_frame(
&mut self,
id: u32,
by_player: bool,
creative: bool,
) -> bool {
let Some(i) = self.item_frames.iter().position(|f| f.id == id) else {
return false;
};
if self.item_frames[i].fixed {
if !creative {
return false;
}
self.item_frames[i].item = None;
}
self.hit_frame(i, by_player, creative);
true
}
/// The non-fixed part of `ItemFrame.hurtServer`: a filled frame gives its
/// item up (`shouldDamageDropItem`), an empty one breaks
/// (`BlockAttachedEntity.hurtServer`). `by_player`: the damage source's
/// entity is the acting player, `creative` when they have infinite
/// materials (no drops).
fn hit_frame(&mut self, i: usize, by_player: bool, creative: bool) {
let with_frame = self.item_frames[i].item.is_none();
let entity_id = self.item_frames[i].id;
if with_frame {
let (kind, pos) = (self.item_frames[i].kind(), self.item_frames[i].pos);
if self.hanging_break_cancelled(kind, pos, true) {
return true;
if self.hanging_break_cancelled(kind, pos, by_player) {
return;
}
}
let old_item = self.item_frames[i]
@@ -380,13 +413,14 @@ impl Game<'_> {
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
self.drop_frame_items(&mut f, with_frame, true);
self.drop_frame_items(&mut f, with_frame, by_player, creative);
if !with_frame {
self.item_frames[i].random = f.random.clone();
}
// BLOCK_CHANGE by the attacker after the drops: ItemFrame.hurtServer
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
let who = by_player.then_some(horizon_client::mobs::Who::Player);
let ctx = self.who_context(who, None);
self.post_game_event("minecraft:block_change", at, ctx);
if !with_frame {
// ItemFrame.hurtServer plays this after the event.
@@ -394,7 +428,9 @@ impl Game<'_> {
.play(&sound(&f, "remove_item"), Some(at), 1.0, 1.0);
}
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
if by_player
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_item_frame_change(
player,
&name,
@@ -404,7 +440,6 @@ impl Game<'_> {
rotation.rem_euclid(8) as u8,
);
}
true
}
/// `ItemFrame.setItem(stack, true)`: keeps one item, plays the add sound
/// for a non-empty stack and updates the comparators.
@@ -428,14 +463,20 @@ impl Game<'_> {
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: &mut ItemFrame, with_frame: bool, by_player: bool) {
fn drop_frame_items(
&mut self,
f: &mut ItemFrame,
with_frame: bool,
by_player: bool,
creative: bool,
) {
// The framed map forgets the frame, except when the drops are off and
// something caused the break (`removeFramedMap` runs only for a null
// `causedBy` then).
if self.ui.rules.bool("entity_drops") || !by_player {
self.remove_framed_map(f);
}
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
if !self.ui.rules.bool("entity_drops") || (by_player && creative) {
return;
}
let s = f.step().map(f64::from);
@@ -498,7 +539,7 @@ impl Game<'_> {
self.entity_killed_event_by(center(&f), self.explosion_kill_credit());
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&mut f, true, false);
self.drop_frame_items(&mut f, true, false, false);
// ItemFrame.dropItem(level, causedBy): BLOCK_CHANGE after the drops.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
@@ -532,7 +573,7 @@ impl Game<'_> {
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
self.drop_frame_items(&mut f, true, false);
self.drop_frame_items(&mut f, true, false, false);
// ItemFrame.dropItem(level, null): sound, drops, then event.
let ctx = self.who_context(None, None);
self.post_game_event("minecraft:block_change", center(&f), ctx);
@@ -0,0 +1,176 @@
//! Production gateway counters and direct phases over a silent, empty Game.
use crate::game::{
boats::Boat,
entity::Entity,
minecarts::{CartKind, Minecart},
};
use horizon_client::{
end_gateway::Traveller,
projectiles::{Projectile, ProjectileKind},
};
use horizon_core::{geometry::Vec3, position::BlockPos};
#[test]
#[ignore = "needs explicit HORIZON_TEST_RESOURCES and HORIZON_PORTAL_REFERENCE"]
fn gateway_counters_match_actual_vanilla_methods() {
let reference: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(std::env::var_os("HORIZON_PORTAL_REFERENCE").unwrap()).unwrap(),
)
.unwrap();
let boat = Boat::new(1, "oak".into(), Vec3::ZERO, 0.0);
let cart = Minecart::new(2, CartKind::Rideable, Vec3::ZERO);
let arrow = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical: false,
effect: None,
},
Vec3::ZERO,
None,
);
let delays = [
boat.dimension_changing_delay(),
cart.dimension_changing_delay(),
arrow.dimension_changing_delay(),
horizon_client::nether_portal::ENTITY_DELAY,
];
assert_eq!(serde_json::json!(delays), reference["delays"]);
let mut travellers: [Traveller; 4] = std::array::from_fn(|_| Traveller::default());
let entry = BlockPos::new(0, 100, 0);
for (traveller, delay) in travellers.iter_mut().zip(delays) {
traveller.enter_with_delay(entry, delay);
assert_eq!(traveller.travel_with_delay(true, delay), Some(entry));
assert_eq!(traveller.cooldown(), delay);
traveller.set_cooldown(5);
}
let mut rows = Vec::new();
for (step, ticks) in [1, 3, 1, 1, 8].into_iter().enumerate() {
for _ in 0..ticks {
for (i, (traveller, delay)) in travellers.iter_mut().zip(delays).enumerate() {
if step < 2 && i == 2 {
traveller.enter_with_delay(entry, delay);
}
traveller.travel_with_delay(true, delay);
if step < 2 && i != 2 {
traveller.enter_with_delay(entry, delay);
}
}
}
rows.push(
travellers
.iter()
.map(Traveller::cooldown)
.collect::<Vec<_>>(),
);
}
let phases = game_gateway_phases();
let report = serde_json::json!({"contact":rows,"phases":phases});
assert_eq!(report["contact"], reference["contact"]);
if let Some(path) = std::env::var_os("HORIZON_PORTAL_REPORT") {
std::fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
}
}
// Exercise the real dispatch with cooldown-only states, so no teleport destination is resolved.
fn game_gateway_phases() -> [[i32; 2]; 4] {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_world::nbt::Tag;
use std::sync::Arc;
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.headless = true;
options.game_dir = Some(std::env::current_dir().unwrap());
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.options.dedicated = Some(Vec::new());
game.world = horizon_client::world::World::new(game.res.facts().air, 1);
for z in -2..=2 {
for x in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.dim = crate::game::Dim::End;
let mut ids = Vec::new();
for (index, kind) in ["minecraft:oak_boat", "minecraft:ender_pearl"]
.into_iter()
.enumerate()
{
let uuid = [11318, 20, 0, index as i32];
let tag = Tag::compound([
("id", Tag::String(kind.into())),
("UUID", Tag::IntArray(uuid.to_vec())),
(
"Pos",
Tag::List(vec![
Tag::Double(4.5f64.to_bits()),
Tag::Double(210.0f64.to_bits()),
Tag::Double(4.5f64.to_bits()),
]),
),
("NoGravity", Tag::Byte(1)),
]);
game.load_listed(kind, Some(uuid), None, tag).unwrap();
ids.push(game.next_entity);
}
assert_eq!(game.boats.len(), 1);
assert_eq!(game.creatures.projectiles.len(), 1);
assert!(matches!(
game.creatures.projectiles[0].kind,
ProjectileKind::EnderPearl
));
game.tickets.update(Some(crate::ticket_levels::Sources {
forced: vec![(0, 0)],
..Default::default()
}));
assert!(game.entity_ticking_area()(4, 4));
for &id in &ids {
let mut state = Traveller::default();
state.set_cooldown(5);
game.end.gateways.insert(id, state);
}
let counters = |game: &Game| {
[
game.end.gateways[&ids[0]].cooldown(),
game.end.gateways[&ids[1]].cooldown(),
]
};
let mut rows = [[0; 2]; 4];
for tick in 0..2 {
game.handle_end_gateway_portals();
rows[tick * 2] = counters(&game);
assert_eq!(rows[tick * 2], [4 - tick as i32, 5 - tick as i32]);
game.handle_end_gateway_portals_phase(true);
rows[tick * 2 + 1] = counters(&game);
assert_eq!(rows[tick * 2 + 1], [4 - tick as i32, 4 - tick as i32]);
}
assert!(game.end.arrivals.is_empty());
assert_eq!(game.creatures.projectiles.len(), 1);
rows
}
+21 -11
View File
@@ -11,6 +11,7 @@ use horizon_client::{
items::ItemStack,
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_items::mining::{self, DestroySpeedModifiers};
/// The point a `ServerboundInteractPacket` carries: `EntityHitResult.getLocation()
/// - entity.position()`, measured from the entity's feet, as
@@ -430,20 +431,29 @@ impl Game<'_> {
return 0.0;
}
let held = self.held();
let mut speed = self.items().mining_speed(held, &facts.name);
let tool_speed = self.items().mining_speed(held, &facts.name);
let hardness = facts.hardness;
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
speed = self.modify_destroy_speed(speed);
speed *= block_break_speed;
if self.mining_eyes_in_water() {
speed *= self.submerged_mining_speed();
}
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
speed /= 5.0;
}
let harvest =
self.items()
.has_correct_tool_for_drops(held, facts.requires_tool, &facts.name);
let player = DestroySpeedModifiers {
// Vanilla reads the attribute (recomputing a dirty one) only then.
mining_efficiency: if tool_speed > 1.0 {
self.mining_efficiency()
} else {
0.0
},
effects: self.potions.effects.mining_speed_factor(),
block_break_speed,
submerged_mining_speed: self
.mining_eyes_in_water()
.then(|| self.submerged_mining_speed()),
on_ground: self.player.as_ref().is_none_or(|p| p.ground.on_ground),
};
let mut speed = mining::destroy_speed(tool_speed, &player);
// NeoForge's PlayerEvent.BreakSpeed, where Player.getDestroySpeed ends.
self.native_break_speed(pos, state, speed, &mut speed);
speed / hardness / if harvest { 30.0 } else { 100.0 }
mining::destroy_progress(hardness, harvest, speed)
}
/// `EntityFluidInteraction`: compare the eyes with the camera fluid surface.
fn mining_eyes_in_water(&self) -> bool {
@@ -147,7 +147,7 @@ impl Game<'_> {
// ItemStack.isDamageableItem: component removal and
// unbreakable are checked before enchantment RNG or hooks.
if stack.count == 0
|| !info.equip_damage_on_hurt
|| !horizon_client::item_sync::damage_on_hurt(&stack, info.equip_damage_on_hurt)
|| component("minecraft:unbreakable").is_some()
|| component("!minecraft:max_damage").is_some()
|| component("!minecraft:damage").is_some()
@@ -242,10 +242,8 @@ impl Game<'_> {
{
stack.damage += amount;
if stack.damage >= max {
let broken = stack.item.clone();
let gone = stack.clone();
*cell = None;
self.award_stat("broken", &broken, 1);
// LivingEntity.onEquippedItemBroken: the sound and the crumbs.
self.player_equipment_broke(break_slot, &gone);
}
@@ -41,6 +41,8 @@ impl Game<'_> {
let mut cells = horizon_client::entities::ItemCells::new(&items);
// A LAN or server guest only shows the host's items.
let merging = self.remote.is_none();
// `Entity.checkBelowWorld`: below `Level.getMinY() - 64`.
let void = f64::from(self.level_min_y() - 64);
let mut obstacles =
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
let mut nearby = Vec::new();
@@ -144,7 +146,7 @@ impl Game<'_> {
2
};
if merging
&& (alive || item.age == 6000 && item.position.y > -128.0)
&& (alive || item.age == 6000 && item.position.y >= void)
&& item.ticks.is_multiple_of(rate)
{
let max_stack = |stack: &ItemStack| self.stack_max_size(stack).max(1);
@@ -157,8 +159,8 @@ impl Game<'_> {
);
}
let item = &mut items[i];
let mut alive = alive || (item.age < 6000 && item.position.y > -128.0);
if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 {
let mut alive = alive || (item.age < 6000 && item.position.y >= void);
if merging && !removed[i] && item.age >= 6000 && item.position.y >= void {
let mut extra_life = 0;
self.native_item_expire(item.id, &item.stack, &mut extra_life);
if extra_life > 0 {
@@ -793,6 +795,20 @@ impl Game<'_> {
self.player_drop(thrown);
}
/// `ServerPlayer.drop(boolean)` for the guest `conn` (`gi` in the hub): the
/// drop key's throw, then `setRemoteSlot` on the selected slot so a pickup
/// that refills the stack is sent to the client that predicted the drop.
pub(super) fn guest_drop_held(
&mut self,
host: &mut super::lan::LanHost,
gi: usize,
conn: u32,
all: bool,
) {
self.as_guest(host, conn, |g| g.drop_held(all));
host.hub.guests[gi].told_selected();
}
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
/// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the
/// drop key) names the player as the thrower; `drop(stack, false)`, for
@@ -1032,3 +1048,7 @@ mod escape_tests {
assert_eq!(game.world.random.next_float(), level.next_float());
}
}
#[cfg(test)]
#[path = "item_void_tests.rs"]
mod item_void_tests;
@@ -0,0 +1,59 @@
//! `Entity.checkBelowWorld`: a dropped item is discarded below its level's
//! floor minus 64, so the Nether and the End lose one at y = -64 while the
//! Overworld keeps it down to y = -128.
use super::super::Dim;
use super::super::native_machines::tests::server_game;
use horizon_client::chunk::Chunk;
use horizon_client::items::ItemStack;
use horizon_client::motion::PlayerMotion;
use horizon_core::geometry::Vec3;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn items_fall_to_each_dimensions_own_void_floor() {
let mut game = server_game();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 70.0, 8.5)).unwrap());
for x in -2..=2 {
for z in -2..=2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let keys: HashSet<(i32, i32)> = [(0, 0)].into();
for (dim, floor) in [
(Dim::Overworld, -128.0),
(Dim::Nether, -64.0),
(Dim::End, -64.0),
] {
game.dim = dim;
game.world.shape = dim.shape();
for (y, survives) in [(floor + 0.5, true), (floor - 0.5, false)] {
game.dropped.clear();
game.spawn_item(
ItemStack::new("minecraft:stone", 1),
[8.5, y, 8.5],
[0.0, 0.0, 0.0],
0,
);
game.ticked_entities_through = u32::MAX;
game.tick_items();
assert_eq!(
!game.dropped.is_empty(),
survives,
"{dim:?}: an item at y {y}, against the floor {floor}"
);
let mut saved = HashMap::new();
game.save_every_entity(&keys, &mut saved);
assert_eq!(
saved.values().map(Vec::len).sum::<usize>() > 0,
survives,
"{dim:?}: an item at y {y} in the save"
);
}
}
}
@@ -77,7 +77,7 @@ impl Game<'_> {
});
}
fn apply_set_jigsaw_block(&mut self, p: &play::SetJigsawBlock) {
pub(super) fn apply_set_jigsaw_block(&mut self, p: &play::SetJigsawBlock) {
let pos = (p.pos[0], p.pos[1], p.pos[2]);
let Some(b) = self.jigsaw_block_mut(pos) else {
return;
@@ -112,6 +112,22 @@ impl Editor for JigsawEditor {
&mut self.edits
}
/// `JigsawBlockEditScreen` gives its two priority boxes their explanation;
/// the other boxes have none.
fn edit_tooltip(&self, edit: usize) -> Option<Text> {
match edit {
SELECTION_PRIORITY => Some(Text::translate(
"jigsaw_block.selection_priority.tooltip",
&[],
)),
PLACEMENT_PRIORITY => Some(Text::translate(
"jigsaw_block.placement_priority.tooltip",
&[],
)),
_ => None,
}
}
fn items(&self, _entity: &BlockEntity, w: f32, _h: f32) -> Vec<Item> {
let mid = (w / 2.0).floor();
let tr = |key: &str| Text::translate(key, &[]);
@@ -329,4 +345,77 @@ mod tests {
e.press(&mut entity, (0, 0, 0), JOINT, false, false);
assert_eq!(e.joint, JointType::Aligned);
}
/// `JigsawBlockEditScreen` sets a tooltip on the two priority boxes and
/// no other.
#[test]
fn the_priority_boxes_explain_themselves() {
let e = editor();
assert_eq!(
e.edit_tooltip(SELECTION_PRIORITY)
.unwrap()
.translation_key(),
Some("jigsaw_block.selection_priority.tooltip")
);
assert_eq!(
e.edit_tooltip(PLACEMENT_PRIORITY)
.unwrap()
.translation_key(),
Some("jigsaw_block.placement_priority.tooltip")
);
for edit in [POOL, NAME, TARGET, FINAL_STATE] {
assert!(e.edit_tooltip(edit).is_none(), "box {edit}");
}
}
#[test]
fn priority_fields_keep_their_labels_values_and_packet_fields() {
let block = JigsawBlock {
selection_priority: 17,
placement_priority: -5,
..JigsawBlock::default()
};
let mut e = JigsawEditor::new(&block, false);
let entity = BlockEntity::JigsawBlock(Box::new(block));
let items = e.items(&entity, 320.0, 240.0);
for (x, label, tip, value, changed) in [
(
7.0,
"jigsaw_block.selection_priority",
"jigsaw_block.selection_priority.tooltip",
"17",
"8",
),
(
110.0,
"jigsaw_block.placement_priority",
"jigsaw_block.placement_priority.tooltip",
"-5",
"3",
),
] {
assert!(items.iter().any(|item| matches!(item,
Item::Label { text, x: lx, y, .. } if *lx == x && *y == 150.0 && text.translation_key() == Some(label)
)));
let index = items
.iter()
.find_map(|item| match item {
Item::Edit { index, x: ex, y, w } if *ex == x && *y == 160.0 && *w == 98.0 => {
Some(*index)
}
_ => None,
})
.expect("priority field below its label");
assert_eq!(e.edits[index].value, value);
assert_eq!(e.edit_tooltip(index).unwrap().translation_key(), Some(tip));
e.edits[index].set_value(changed);
}
let S::SetJigsawBlock(packet) = e.set_packet((0, 0, 0)).unwrap() else {
panic!("sends jigsaw fields");
};
assert_eq!(
(packet.selection_priority, packet.placement_priority),
(8, 3)
);
}
}
+5 -5
View File
@@ -3127,17 +3127,17 @@ impl Game<'_> {
self.remote_totem(id);
}
}
/// `LivingEntity.onEquippedItemBroken` for the acting player (the guest whose turn it
/// is, else the host): `level.broadcastEntityEvent` with `entityEventForEquipmentBreak`
/// of the slot, which each client answers with `breakItem` (the sound and the crumbs,
/// `remote_equipment_break`). Without a hub to tell, or in a window that runs the world
/// itself, the sound and the crumbs are played here.
/// `ServerPlayer.onEquippedItemBroken` for the acting player (the guest whose turn it
/// is, else the host): the broken statistic, then `level.broadcastEntityEvent` of the
/// slot's break event, which each client answers with `breakItem` (the sound and the
/// crumbs, `remote_equipment_break`). Without a hub to tell, they are played here.
pub(super) fn player_equipment_broke(
&mut self,
slot: horizon_world::player_inventory::EquipmentSlot,
stack: &horizon_client::items::ItemStack,
) {
use horizon_world::player_inventory::EquipmentSlot;
self.award_stat("broken", &stack.item, 1);
let event = match slot {
EquipmentSlot::MainHand => 47,
EquipmentSlot::OffHand => 48,
@@ -2312,7 +2312,7 @@ impl Game<'_> {
}
4 | 5 => {
if !spectator {
self.as_guest(host, conn, |g| g.drop_held(a.action == 4));
self.guest_drop_held(host, gi, conn, a.action == 4);
}
}
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
@@ -4502,7 +4502,7 @@ impl Game<'_> {
let me = self.player_text(&name, uuid);
let text = match cause {
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
None if at[1] < f64::from(self.world.bottom_y() - 64) => {
None if at[1] < self.world.below_world_y() => {
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
}
None => self.guest_death_message(conn, me),
@@ -202,6 +202,8 @@ impl Game<'_> {
self.as_guest(host, conn, |g| {
let player = g.native_acting_id();
for (recipe, stack) in &item_crafted {
// ItemStack.onCraftedBy awards Stats.ITEM_CRAFTED.
g.award_stat("crafted", &stack.item, stack.count as i32);
g.native_item_crafted(player, recipe, stack);
}
});
@@ -573,7 +573,7 @@ impl Game<'_> {
let corner = |max: bool| {
[Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) })
};
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true));
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true), &host.hub.encoder);
let allow_flight = host.hub.config.allow_flight;
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
@@ -119,6 +119,10 @@ impl Loading {
}
};
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
#[cfg(target_os = "windows")]
if let Some(hwnd) = hwnd(&window) {
horizon_threads::system_menu::suppress(hwnd);
}
drop(window_step);
horizon_client::telemetry::note_display(&window);
// The renderer takes seconds to build and winit's window sits white
@@ -602,6 +602,9 @@ impl Game<'_> {
let equipped = std::mem::take(&mut menu.equipped);
let item_crafted = std::mem::take(&mut menu.item_crafted);
for (_, stack) in &item_crafted {
if !predicting {
self.award_stat("crafted", &stack.item, stack.count as i32);
}
self.tutorial_get_item(Some(stack));
}
for (i, stack) in after.into_iter().enumerate() {
+1 -4
View File
@@ -281,10 +281,7 @@ impl Game<'_> {
Some(format!("minecraft:{}", nautilus::saddle_sound(m)))
} else {
put_on.map(|s| {
self.res
.items()
.get(&s.item)
.and_then(|i| i.equip_sound.clone())
horizon_client::item_sync::equip_sound(s, self.res.items())
.unwrap_or_else(|| "minecraft:item.armor.equip_generic".into())
})
};
-1
View File
@@ -280,7 +280,6 @@ impl Game<'_> {
// `hurtAndConvertOnBreak`: the stick breaks (the broken
// statistic and `onEquippedItemBroken`), then what is
// left is the rod, not a fresh plain one.
self.award_stat("broken", &stack.item, 1);
self.player_equipment_broke(
horizon_world::player_inventory::EquipmentSlot::MainHand,
&stack,
@@ -125,8 +125,7 @@ impl Game<'_> {
let mut vertices = Vec::new();
self.particles.render(
glam::DVec3::from_array(at),
0.0,
0.0,
glam::Quat::IDENTITY,
1.0,
&|_| 15,
&mut vertices,
@@ -167,8 +166,7 @@ impl Game<'_> {
vertices.clear();
self.particles.render(
glam::DVec3::from_array(at),
0.0,
0.0,
glam::Quat::IDENTITY,
1.0,
&|_| 15,
&mut vertices,
@@ -86,6 +86,10 @@ impl Game<'_> {
fn take_blast_state(&mut self) -> BlastState {
let c = &mut self.combat;
BlastState {
explicit_cause: c.explicit_explosion_cause.take(),
blast_owner: c.blast_owner.take(),
blast_guest: c.blast_guest.take(),
explicit_direct: c.explicit_explosion_direct.take(),
source: c.explosion_source.take(),
cause: c.explosion_cause.take(),
source_named: std::mem::take(&mut c.explosion_source_named),
@@ -93,12 +97,19 @@ impl Game<'_> {
hive_emptying: std::mem::take(&mut c.hive_emptying_blast),
rail_proof: std::mem::take(&mut c.rail_proof_blast),
owner: c.explosion_owner.take(),
tnt_owner: c.blast_owner.take(),
tnt_guest: c.blast_guest.take(),
tnt_owner_player: std::mem::take(&mut c.blast_owner_player),
}
}
/// Puts back what `take_blast_state` cleared.
fn restore_blast_state(&mut self, s: BlastState) {
let c = &mut self.combat;
c.explicit_explosion_cause = s.explicit_cause;
c.blast_owner = s.blast_owner;
c.blast_guest = s.blast_guest;
c.explicit_explosion_direct = s.explicit_direct;
c.explosion_source = s.source;
c.explosion_cause = s.cause;
c.explosion_source_named = s.source_named;
@@ -106,11 +117,18 @@ impl Game<'_> {
c.hive_emptying_blast = s.hive_emptying;
c.rail_proof_blast = s.rail_proof;
c.explosion_owner = s.owner;
c.blast_owner = s.tnt_owner;
c.blast_guest = s.tnt_guest;
c.blast_owner_player = s.tnt_owner_player;
}
}
/// The fields of `CombatTracker` that describe the blast going off now.
struct BlastState {
explicit_cause: Option<Option<super::explosion::ExplosionCause>>,
blast_owner: Option<(u32, horizon_client::text::Text)>,
blast_guest: Option<u32>,
explicit_direct: Option<horizon_client::text::Text>,
source: Option<u32>,
cause: Option<horizon_client::advancements::EntityView>,
source_named: bool,
@@ -118,6 +136,9 @@ struct BlastState {
hive_emptying: bool,
rail_proof: bool,
owner: Option<u32>,
tnt_owner: Option<(u32, horizon_client::text::Text)>,
tnt_guest: Option<u32>,
tnt_owner_player: bool,
}
#[cfg(test)]
@@ -366,6 +366,17 @@ impl Game<'_> {
view.x_rot,
))
}
/// `Camera.rotation()` as the quads of particles take it: the frame's camera (a
/// bed's, a front view's, a mod's), not the player's own yaw and pitch.
pub(super) fn particle_rotation(&self) -> glam::Quat {
if self.studio.flying() {
return self.camera_orientation();
}
let (yaw, pitch) = self
.posed_rotation
.unwrap_or((self.view.y_rot, self.view.x_rot));
super::camera_rotation(CameraType::FirstPerson, yaw, pitch)
}
/// The player as `Camera.alignWithEntity` sees it.
fn player_camera_entity(&mut self) -> Option<CameraEntity> {
let (scale, distance) = {
@@ -65,12 +65,13 @@ impl Game<'_> {
return;
};
let at = [at.x, at.y, at.z];
let equippable = new
.and_then(|s| self.items().get(&s.item))
.and_then(|info| {
let own = info.equip_slot.as_deref()?;
Some((own == slot, info.equip_sound.clone()))
});
let equippable = new.and_then(|stack| {
let equippable = horizon_client::item_sync::equippable(stack, self.items())?;
Some((
equippable.slot == slot,
horizon_client::item_sync::equip_sound(stack, self.items()),
))
});
if let Some((true, sound)) = &equippable {
let sound = sound
.as_deref()
+2 -2
View File
@@ -1410,7 +1410,7 @@ impl Game<'_> {
return false;
}
if deflect_stop {
return t.age < 1200 && t.position.y > -128.0;
return t.age < 1200 && t.position.y >= self.world.below_world_y();
}
t.position = next;
let bounds = Aabb::new(
@@ -1418,7 +1418,7 @@ impl Game<'_> {
next.add_vector(Vec3::new(0.125, 0.25, 0.125)),
);
self.entity_powder_snow_particles(t.previous, next, bounds, t.velocity, false);
t.age < 1200 && t.position.y > -128.0
t.age < 1200 && t.position.y >= self.world.below_world_y()
});
self.potions.thrown = thrown;
for id in deflected {
@@ -579,6 +579,9 @@ impl Game<'_> {
..last
});
let mut mob = self.mobs.remove(i);
// #15294: the fall, lava or a wall it runs into hurt the server's
// horse, not this copy (`Entity.hurt` on a client level).
mob.extra.client_side = true;
let mut random = self.world.random.clone();
let mut events = Vec::new();
{
@@ -268,7 +268,7 @@ impl Game<'_> {
}
}
/// `Level.getMinY`: the current dimension type's minimum y.
fn level_min_y(&self) -> i32 {
pub(super) fn level_min_y(&self) -> i32 {
match self.remote.as_ref() {
Some(r) => r.dimension.min_y,
None => self.world.shape.min_y,
@@ -352,6 +352,7 @@ impl Game<'_> {
// ServerList.saveSingleServer.
if let Some(address) = &self.server_packs.address {
let file = multiplayer::servers_file(&saves());
let _guard = servers::lock();
let (mut list, mut hidden) = servers::load_all(&file);
let mut found = false;
for entry in list.iter_mut().filter(|s| &s.ip == address) {
+3
View File
@@ -503,6 +503,8 @@ impl Game<'_> {
},
|id| self.mob_display_name(id),
);
self.combat.blast_owner_player =
owner == Some(Who::Player) && self.combat.blast_owner.is_some();
let result = self.with_hive_emptying_blast(|g| {
g.server_explode_from(
center,
@@ -515,6 +517,7 @@ impl Game<'_> {
)
});
self.combat.blast_owner = None;
self.combat.blast_owner_player = false;
self.combat.blast_guest = None;
self.combat.explosion_in_water = false;
result?;
+5 -1
View File
@@ -1084,12 +1084,16 @@ impl Game<'_> {
}
self.thrower_dealt_absorbed(t.guest);
// ThrownTrident.onHitEntity on a boat or minecart:
// VehicleEntity.hurtServer takes the damage.
// VehicleEntity.hurtServer takes the damage; an item
// frame's ItemFrame.hurtServer.
let creative = t.guest.is_none() && self.creative;
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.hurt_boat(i, damage, creative);
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
self.hurt_cart(i, damage, creative, false);
} else {
// ItemFrame.hurtServer: the item comes out, or the frame breaks.
self.projectile_hurts_frame(id, true, creative);
}
// ThrownTrident.onHitEntity on another player.
if let Some(conn) = self.trident_guest_conn(id) {
+4 -1
View File
@@ -145,7 +145,8 @@ impl Game<'_> {
burst
}
/// A projectile's hurt of `amount` on the boat or minecart `id`
/// (`VehicleEntity.hurtServer`): even 0 shakes it (`Snowball`, `ThrownEgg`,
/// (`VehicleEntity.hurtServer`), or the item frame (`ItemFrame.hurtServer`,
/// which any non-explosion hit makes give up its item): even 0 shakes it (`Snowball`, `ThrownEgg`,
/// `ThrownEnderpearl` `.onHitEntity`), 5 from a small fireball destroys it.
pub(super) fn hurt_vehicle(&mut self, p: &Projectile, id: u32, amount: f32) {
let creative = p.owner == Some(Who::Player) && self.creative;
@@ -153,6 +154,8 @@ impl Game<'_> {
self.hurt_boat(i, amount, creative);
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
self.hurt_cart(i, amount, creative, false);
} else {
self.projectile_hurts_frame(id, p.owner == Some(Who::Player), creative);
}
}
/// The boats and minecarts of this tick's projectile pass.
@@ -314,6 +314,10 @@ impl ApplicationHandler for Game<'_> {
}
};
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
#[cfg(target_os = "windows")]
if let Some(hwnd) = hwnd(&window) {
horizon_threads::system_menu::suppress(hwnd);
}
drop(window_step);
horizon_client::telemetry::note_display(&window);
match self.new_renderer(window.clone()) {
+97
View File
@@ -194,6 +194,39 @@ pub fn equippable(stack: &ItemStack, items: &crate::items::Items) -> Option<Equi
out
}
/// `Equippable.equipSound` from the stack's component, else the item's.
/// A present component without the field is the generic armour sound (None),
/// not the prototype's.
pub fn equip_sound(stack: &ItemStack, items: &crate::items::Items) -> Option<String> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
return None;
}
if let Some(tag) = data.and_then(|d| d.other("minecraft:equippable")) {
return match tag.get("equip_sound") {
Some(Tag::String(sound)) => Some(sound.to_string_lossy()),
_ => None,
};
}
items.get(&stack.item).and_then(|i| i.equip_sound.clone())
}
/// `Equippable.damageOnHurt`: a present component's field (default true),
/// a removed component false, else the item's prototype.
pub fn damage_on_hurt(stack: &ItemStack, default: bool) -> bool {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
return false;
}
if let Some(tag) = data.and_then(|d| d.other("minecraft:equippable")) {
return tag
.get("damage_on_hurt")
.and_then(crate::item_data::component_bool)
.unwrap_or(true);
}
default
}
/// `stack.get(DataComponents.CONSUMABLE)`: the item's own, replaced or removed
/// by the saved components (a command's, a save's), then by the server's patch.
pub fn consumable(stack: &ItemStack, items: &crate::items::Items) -> Option<ItemConsumable> {
@@ -1124,7 +1157,71 @@ pub fn profile_tag(p: &Profile) -> Tag {
mod tests {
use super::*;
use horizon_protocol::registry::Ids;
use std::path::Path;
/// `Equippable.equipSound` is the stack's component's own: a replaced
/// component without one plays the generic sound, a removed one none.
#[test]
fn equippable_equip_sound_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:golden_helmet", 1);
stack.data_mut().set_other(
"minecraft:equippable",
Some(Tag::compound([("slot", Tag::String("head".into()))])),
);
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let items =
crate::items::Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
assert_eq!(equip_sound(&stack, &items), None);
stack.data_mut().set_other(
"minecraft:equippable",
Some(Tag::compound([
("slot", Tag::String("head".into())),
(
"equip_sound",
Tag::String("minecraft:item.armor.equip_gold".into()),
),
])),
);
assert_eq!(
equip_sound(&stack, &items).as_deref(),
Some("minecraft:item.armor.equip_gold")
);
stack.data_mut().set_other(
"!minecraft:equippable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(equip_sound(&stack, &items), None);
}
/// `Equippable.damageOnHurt` is the stack's component's own: true when a
/// replaced component leaves it out, false when the component is removed.
#[test]
fn equippable_damage_on_hurt_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:golden_helmet", 1);
assert!(damage_on_hurt(&stack, true));
stack.data_mut().set_other(
"minecraft:equippable",
Some(Tag::compound([
("slot", Tag::String("head".into())),
("damage_on_hurt", Tag::Byte(0)),
])),
);
assert!(!damage_on_hurt(&stack, true));
assert!(!damage_on_hurt(&stack, false));
assert!(!damage_on_hurt(
&ItemStack::new("minecraft:golden_helmet", 1),
false
));
stack.data_mut().set_other(
"!minecraft:equippable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(!damage_on_hurt(&stack, true));
}
/// A chestplate a server made a glider: its patch adds `glider` and an
/// `equippable` for the chest, or takes `glider` away again.
#[test]
+50
View File
@@ -906,6 +906,22 @@ impl Guest {
self.menu_sent[slot] = self.menu_slot(slot);
}
}
/// `ServerPlayer.drop(boolean)`: `setRemoteSlot` on the selected hotbar
/// slot of the open menu (the inventory menu without one) records what the
/// dropping client already predicted, so the next broadcast sends the slot
/// again once it changes back (a pickup refilling the stack).
pub fn told_selected(&mut self) {
let slot = 36 + self.selected;
self.menu_sent[slot] = self.menu_slot(slot);
let told = self.inventory.get(self.selected).cloned().flatten();
if let Some(m) = self.menu.as_mut() {
let at = m.size + 27 + self.selected;
if m.sent.len() <= at {
m.sent.resize(at + 1, None);
}
m.sent[at] = told;
}
}
pub fn set_menu_slot(&mut self, slot: usize, item: Option<ItemStack>) {
match slot {
0 => self.craft_result = item,
@@ -2303,4 +2319,38 @@ mod tests {
);
}
}
#[test]
fn told_selected_records_the_predicted_drop() {
use crate::lan::PlayerSender;
use std::sync::Arc;
use std::sync::atomic::AtomicI32;
let (tx, _rx) = std::sync::mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 2,
name: "Dropper".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[0.5, 64.0, 0.5],
0,
);
guest.selected = 3;
guest.inventory[3] = Some(ItemStack::new("minecraft:netherrack", 63));
guest.told_selected();
// Dropping one is already known to the client (63); the pickup that
// refills the slot must then differ from `menu_sent` and be sent.
assert_eq!(guest.menu_sent[39].as_ref().map(|s| s.count), Some(63));
guest.inventory[3].as_mut().unwrap().count = 64;
assert_ne!(guest.menu_sent[39], guest.menu_slot(39));
}
}
+10 -31
View File
@@ -38,6 +38,7 @@ use horizon_world::{
};
use std::sync::{Arc, Mutex, atomic::Ordering};
mod accept_teleport;
mod acknowledgements;
/// GameEvent types (ClientboundGameEventPacket).
pub mod chat;
@@ -3953,32 +3954,7 @@ impl Hub {
| S::PlayerCommand(_)
if !self.guests[i].client_loaded() => {}
// handleAcceptTeleportPacket.
S::AcceptTeleportation(p) => {
let recording = self.recording();
let g = &mut self.guests[i];
if let Some((id, at)) = g.awaiting_teleport
&& id == p.id
{
if p.x.is_nan()
|| p.y.is_nan()
|| p.z.is_nan()
|| !p.yaw.is_finite()
|| !p.pitch.is_finite()
{
self.kick(conn, invalid_movement());
continue;
}
g.position = at;
g.awaiting_teleport = None;
// The prediction's replay starts again where the client now is.
if recording {
let kept = g.awaiting_keeps_motion;
g.record.get_or_insert_default().teleported(at, kept);
}
let rot = (p.yaw, p.pitch);
self.position_change(i, [p.x, p.y, p.z], rot, 0, ctx, events);
}
}
S::AcceptTeleportation(p) => self.accept_teleport(i, conn, &p, ctx, events),
// handlePlayerAbilities: the client flies only if it may.
S::PlayerAbilities(p) => {
let g = &mut self.guests[i];
@@ -4480,7 +4456,7 @@ impl Hub {
at[1] + f64::from(d.height) + 0.0625,
at[2] + half,
];
no_blocks_in(world, lo, hi)
no_blocks_in(world, lo, hi, &self.encoder)
}
/// `ServerGamePacketListenerImpl.forceSendPlayerSupportBlocks`: the blocks just under the
@@ -5084,6 +5060,9 @@ impl Hub {
let g = &mut self.guests[i];
g.landing = true;
g.awaiting_teleport = None;
// The new level's first teleport takes the next id, so the old one's late accept
// is no longer the last id sent.
g.teleport_counter = (g.teleport_counter + 1) & 0x7FFF_FFFF;
} else {
self.resend_teleport(i, None);
}
@@ -5168,14 +5147,14 @@ const PLAYER_GRAVITY: f64 = 0.08;
/// `ServerGamePacketListenerImpl.getMaximumFlyingTicks`: 80 ticks, more where
/// gravity is weaker, none at all (never kicked) without gravity.
/// `ServerGamePacketListenerImpl.noBlocksAround` over a box: every block from `lo` to `hi` is air, in
/// loaded chunks (a chunk not loaded yet is not known to be air: nothing floats there).
pub fn no_blocks_in(world: &World, lo: [f64; 3], hi: [f64; 3]) -> bool {
/// `ServerGamePacketListenerImpl.noBlocksAround` over a box: every block from `lo` to `hi` is
/// `isAir()` (`air`'s state table), in loaded chunks (a chunk not loaded yet is not known to be air).
pub fn no_blocks_in(world: &World, lo: [f64; 3], hi: [f64; 3], air: &ChunkEncoder) -> bool {
let cell = |v: f64| v.floor() as i32;
(cell(lo[0])..=cell(hi[0])).all(|x| {
(cell(lo[2])..=cell(hi[2])).all(|z| {
world.loaded(x, z)
&& (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z)))
&& (cell(lo[1])..=cell(hi[1])).all(|y| air.is_air(world.block(x, y, z).0))
})
})
}
@@ -0,0 +1,45 @@
//! `ServerGamePacketListenerImpl.handleAcceptTeleportPacket`.
use super::*;
impl Hub {
/// An accept of `awaitingTeleport`, the last id sent: with no teleport awaited
/// (`awaitingPositionFromClient` null) or a non-finite position it is invalid
/// movement and disconnects; otherwise the player is where the teleport put them.
/// An id that is not the last one sent is ignored.
pub(super) fn accept_teleport(
&mut self,
i: usize,
conn: u32,
p: &play::AcceptTeleportation,
ctx: &mut TickCtx,
events: &mut Vec<HostEvent>,
) {
let recording = self.recording();
let g = &mut self.guests[i];
if p.id != g.teleport_counter {
return;
}
let Some((_, at)) = g.awaiting_teleport else {
self.kick(conn, invalid_movement());
return;
};
if p.x.is_nan()
|| p.y.is_nan()
|| p.z.is_nan()
|| !p.yaw.is_finite()
|| !p.pitch.is_finite()
{
self.kick(conn, invalid_movement());
return;
}
g.position = at;
g.awaiting_teleport = None;
// The prediction's replay starts again where the client now is.
if recording {
let kept = g.awaiting_keeps_motion;
g.record.get_or_insert_default().teleported(at, kept);
}
let rot = (p.yaw, p.pitch);
self.position_change(i, [p.x, p.y, p.z], rot, 0, ctx, events);
}
}
@@ -0,0 +1,90 @@
//! `ServerGamePacketListenerImpl.noBlocksAround` filters the player's box on
//! `BlockBehaviour.BlockStateBase::isAir`, so the air of a cave (`cave_air`) lets nothing
//! block it: a survival guest hovering in a mineshaft is floating, and the server kicks it
//! after `getMaximumFlyingTicks` (80) ticks with "Flying is not enabled on this server".
use super::*;
#[test]
fn a_guest_hovering_in_cave_air_is_kicked_for_flying() {
// The prepared 26.3 data: the real cave_air state, its facts and collision shapes.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("cached-collision.nbt").exists() {
return; // CI has no vanilla data.
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap();
let collision = crate::collisions::load(&root, registry).unwrap();
let air = registry.default_state("minecraft:air").unwrap();
let cave_air = registry.default_state("minecraft:cave_air").unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
let (mut hub, _rx) = hub_with_a_guest();
// The hub's state table knows the real air blocks, as the game's does.
hub.encoder = ChunkEncoder::new(
facts
.states
.iter()
.map(|s| (s.name.clone(), s.fluid_level > 0 || s.waterlogged)),
&horizon_protocol::registry::Builtin::default(),
&[],
&[],
);
hub.guests[0].position = [0.5, 64.0, 0.5];
// The body's box (0.6 wide, 1.8 tall at 64) covers (0, 63..=65, 0): a cave's air.
let mut world = World::new(air, 0);
let mut chunk = crate::chunk::Chunk::empty(0, 0);
for y in 63..=65 {
chunk.set(0, y, 0, cave_air.0);
}
chunk.lit = true;
world.chunks.insert((0, 0), std::sync::Arc::new(chunk));
assert!(
hub.no_blocks_around(0, [0.5, 64.0, 0.5], &world),
"cave_air is air"
);
// One stone block in the box is not air (a cave_air block is not a hole in this test).
world.set(0, 64, 0, stone).unwrap();
assert!(!hub.no_blocks_around(0, [0.5, 64.0, 0.5], &world));
world.set(0, 64, 0, cave_air).unwrap();
// A survival guest hovering there: no step up, nothing to stand on, nothing around.
let mut events = Vec::new();
let border = crate::worldborder::WorldBorder::default();
let mut next_entity = 2;
let ctx = TickCtx {
sea_level: 63,
world: &mut world,
entities: &[],
next_entity: &mut next_entity,
spawn: [0.5, 64.0, 0.5],
spawn_angle: [0.0; 2],
difficulty: 2,
difficulty_locked: false,
clock_rate: 1.0,
tick_rate: 20.0,
tick_frozen: false,
tick_steps: Default::default(),
collision: Some(&collision),
border: &border,
streaming: None,
};
hub.position_change(0, [0.5, 64.0, 0.5], (0.0, 0.0), 0, &ctx, &mut events);
assert!(hub.guests[0].client_is_floating);
// getMaximumFlyingTicks(0.08) is 80: the 81st tick is the one that kicks.
for _ in 0..80 {
hub.tick_floating(0, &mut events);
}
assert!(events.is_empty(), "80 ticks of hovering are allowed");
hub.tick_floating(0, &mut events);
assert!(
matches!(&events[..], [HostEvent::Warn(line)] if line.contains("floating too long")),
"one kick warning"
);
assert!(
matches!(&hub.pending_kicks[..],
[HostEvent::Kicked { reason, .. }] if reason.to_lowercase().contains("flying")),
"the guest is kicked for flying"
);
}
@@ -1,3 +1,5 @@
#[path = "hub/tests/cave_air_tests.rs"]
mod cave_air_tests;
#[path = "hub/far_tests.rs"]
mod far_tests;
#[path = "hub/tests/sound_tests.rs"]
@@ -1378,3 +1380,41 @@ fn memory_connection_first_view_sends_every_ready_column() {
"an empty queue sends nothing"
);
}
/// `ServerGamePacketListenerImpl.handleAcceptTeleportPacket`: an accept whose id is
/// the last teleport's (`awaitingTeleport`) while none is awaited any more
/// (`awaitingPositionFromClient` is null) disconnects with
/// `multiplayer.disconnect.invalid_player_movement`; an accept of any other id is
/// ignored, and the first accept of the awaited one moves the player as before.
#[test]
fn a_second_accept_of_the_same_teleport_disconnects() {
let (mut hub, _rx) = hub_with_a_guest();
let mut world = floor_at(0);
let accept = |id: i32| {
S::AcceptTeleportation(play::AcceptTeleportation {
id,
x: 5.0,
y: 70.0,
z: 5.0,
yaw: 0.0,
pitch: 0.0,
})
};
hub.teleport(0, Some([5.0, 70.0, 5.0]));
let id = hub.guests[0].teleport_counter;
assert!(hub.guests[0].awaiting_teleport.is_some());
// An id nobody sent is ignored while one is awaited.
tick_packet(&mut hub, &mut world, accept(id + 1));
assert!(hub.guests[0].awaiting_teleport.is_some());
assert!(hub.guests[0].left_reason.is_empty());
// The awaited one is accepted once.
tick_packet(&mut hub, &mut world, accept(id));
assert!(hub.guests[0].awaiting_teleport.is_none());
assert!(hub.guests[0].left_reason.is_empty());
// Another id with nothing awaited is ignored.
tick_packet(&mut hub, &mut world, accept(id + 1));
assert!(hub.guests[0].left_reason.is_empty());
// The same id again, with nothing awaited: disconnected.
tick_packet(&mut hub, &mut world, accept(id));
assert!(!hub.guests[0].left_reason.is_empty());
}
+1 -6
View File
@@ -2745,12 +2745,7 @@ impl Menu {
if after_take || matches!(self.kind, Kind::Inventory | Kind::Crafting) {
// A merchant result reports the stack it moved, a crafting result the copy it
// takes (which Item.onCraftedBy may still change).
let taken = if self.slots[index].role == Role::MerchantResult {
&mut stack
} else {
&mut clicked
};
self.on_take(env, index, taken);
self.take_after_quick_move(env, index, &mut clicked, &mut stack);
}
// What the result slot's move left over is dropped.
if (index == 0 && matches!(self.kind, Kind::Inventory | Kind::Crafting))
+23
View File
@@ -316,6 +316,29 @@ struct Grid {
}
impl Menu {
/// `on_take` for a quick-moved slot. A merchant result reports the stack it moved. A
/// crafting result reports the copy it takes (which Item.onCraftedBy may still change)
/// minus what stayed behind: `ResultSlot.onQuickCraft` counts only the items that moved.
pub(super) fn take_after_quick_move(
&mut self,
env: &Env,
index: usize,
clicked: &mut ItemStack,
stack: &mut ItemStack,
) {
if self.slots[index].role == super::Role::MerchantResult {
return self.on_take(env, index, stack);
}
let left = if self.slots[index].role == super::Role::CraftResult {
stack.count
} else {
0
};
clicked.count -= left;
self.on_take(env, index, clicked);
clicked.count += left;
}
fn place_grid(&self) -> Option<Grid> {
match self.kind {
Kind::Inventory => Some(Grid {
@@ -127,6 +127,29 @@ fn a_transfer_of_all_fills_as_many_sets_as_the_items_allow() {
});
}
/// A quick-moved crafting result counts only the items that moved, not the
/// whole result stack (`ResultSlot.onQuickCraft`).
#[test]
fn quick_move_crafting_records_only_items_moved() {
for kind in [Kind::Crafting, Kind::Inventory] {
with_menu(kind, |env, menu| {
menu.creative = false;
menu.set_slot(env, 1, stack("oak_log", 1));
let start = if kind == Kind::Crafting { 10 } else { 9 };
let end = if kind == Kind::Crafting { 46 } else { 45 };
for slot in start..end {
menu.set_slot(env, slot, stack("stone", 64));
}
menu.set_slot(env, end - 1, stack("oak_planks", 62));
menu.settle(env);
menu.click(env, 0, 0, crate::menu::Input::QuickMove)
.unwrap();
assert_eq!(menu.slot(end - 1).unwrap().count, 64);
assert_eq!(menu.item_crafted[0].1.count, 2);
});
}
}
/// A viewer's transfer takes a worn tool, as crafting by hand does (JEI's
/// "+"), while the recipe book's placement keeps vanilla's
/// `isUsableForCrafting` and leaves it.
+32 -301
View File
@@ -82,6 +82,7 @@ pub mod random_textures;
pub mod ravager;
pub mod render;
pub mod riders;
mod seen;
pub mod seen_grid;
pub mod sheep_dye;
pub mod shulker;
@@ -112,6 +113,9 @@ pub mod warden;
pub mod warden_brain;
pub mod witch;
pub mod wither;
#[cfg(test)]
mod invisible_follow_tests;
mod world;
pub use world::{TargetInfo, single_player_pos, visual_clip};
pub mod dragon_contacts;
@@ -127,221 +131,10 @@ use horizon_world::block_collision::BlockCollisionRegistry;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
#[derive(Clone, Debug)]
pub struct Polygon {
pub normal: [f32; 3],
pub vertices: [[f32; 5]; 4],
/// The model's render type culls back faces (`entityCutoutCull`: the bat,
/// the baby turtle).
pub cull_back: bool,
}
impl Polygon {
fn vertex_normal(&self, pose: ModelNormals) -> [i8; 4] {
let n = pose.matrix * glam::Vec3::from_array(self.normal);
let n = if pose.trusted {
n
} else {
n.normalize_or_zero()
};
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
let w = if self.cull_back {
crate::mesher::SHADED_CULL_W
} else {
-127
};
[packed[0], packed[1], packed[2], w]
}
}
mod model;
use model::ModelNormals;
pub use model::{Part, Polygon};
/// ModelPart uses a separate normal stack: uniform scaling keeps normal lengths,
/// while nonuniform scaling requires normalization after the complete part transform.
#[derive(Clone, Copy)]
struct ModelNormals {
matrix: glam::Mat3,
trusted: bool,
}
impl ModelNormals {
// shortcut: infer the root normal pose from its geometry matrix; pass an explicit
// root normal stack when root-transform rounding is ported as well.
fn new(base: glam::Mat4) -> Self {
let m = glam::Mat3::from_mat4(base);
let lengths = glam::Vec3::new(m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
let uniform =
lengths.min_element() > 0.0 && lengths.max_element() - lengths.min_element() < 1.0e-6;
Self {
matrix: if uniform {
m / lengths.x
} else {
m.inverse().transpose()
},
trusted: uniform,
}
}
fn rotation(x: f32, y: f32, z: f32) -> glam::Mat3 {
// JOML derives cosine from the rounded sine, then retains quaternion length.
// Assuming an exactly unit quaternion changes a packed normal by one byte.
let sin_cos = |angle: f32| {
let angle = angle * 0.5;
let sin = f64::from(angle).sin() as f32;
let cos = (1.0 - sin * sin).sqrt();
let a = angle + std::f32::consts::FRAC_PI_2;
let tau = std::f32::consts::TAU;
let mut quadrant = a - (a / tau) as i32 as f32 * tau;
if quadrant < 0.0 {
quadrant += tau;
}
(
sin,
if quadrant >= std::f32::consts::PI {
-cos
} else {
cos
},
)
};
let (sx, cx) = sin_cos(x);
let (sy, cy) = sin_cos(y);
let (sz, cz) = sin_cos(z);
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
let (w, x, y, z) = (
cx * cycz + sx * sysz,
sx * cycz - cx * sysz,
cx * sycz + sx * cysz,
cx * cysz - sx * sycz,
);
let (ww, xx, yy, zz) = (w * w, x * x, y * y, z * z);
let (xy, xz, xw, yz, yw, zw) = (x * y, x * z, x * w, y * z, y * w, z * w);
glam::Mat3::from_cols_array(&[
ww + xx - zz - yy,
(xy + xy) + (zw + zw),
(xz + xz) - (yw + yw),
(xy + xy) - (zw + zw),
yy - zz + ww - xx,
(yz + yz) + (xw + xw),
(yw + yw) + (xz + xz),
(yz + yz) - (xw + xw),
zz - yy - xx + ww,
])
}
fn part(mut self, pose: [f32; 9]) -> Self {
self.matrix *= Self::rotation(pose[3], pose[4], pose[5]);
let scale = glam::Vec3::new(pose[6], pose[7], pose[8]);
if scale.x.abs() == scale.y.abs() && scale.x.abs() == scale.z.abs() {
if scale.min_element() < 0.0 {
self.matrix *= glam::Mat3::from_diagonal(scale.signum());
}
} else {
self.matrix *= glam::Mat3::from_diagonal(scale.recip());
self.trusted = false;
}
self
}
fn part_rotated(mut self, pose: [f32; 9], rotation: glam::Mat3) -> Self {
self.matrix *= rotation;
let scale = glam::Vec3::new(pose[6], pose[7], pose[8]);
if scale.x.abs() == scale.y.abs() && scale.x.abs() == scale.z.abs() {
if scale.min_element() < 0.0 {
self.matrix *= glam::Mat3::from_diagonal(scale.signum());
}
} else {
self.matrix *= glam::Mat3::from_diagonal(scale.recip());
self.trusted = false;
}
self
}
}
#[derive(Clone, Debug)]
pub struct Part {
pub name: String,
/// x, y, z, xRot, yRot, zRot, xScale, yScale, zScale
pub pose: [f32; 9],
pub polygons: Vec<Polygon>,
pub children: Vec<Part>,
}
impl Part {
/// Marks every polygon as drawn by a back-face-culling render type.
fn set_cull_back(&mut self) {
for poly in &mut self.polygons {
poly.cull_back = true;
}
for child in &mut self.children {
child.set_cull_back();
}
}
fn parse(name: &str, v: &Value) -> Self {
let pose: Vec<f32> = v["pose"]
.as_array()
.into_iter()
.flatten()
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
.collect();
let mut p = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
for (i, x) in pose.iter().enumerate().take(9) {
p[i] = *x;
}
let polygons: Vec<Polygon> = v["polygons"]
.as_array()
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, poly)| {
let verts: Vec<[f32; 5]> = poly
.as_array()?
.iter()
.map(|vx| {
let a: Vec<f32> = vx
.as_array()
.into_iter()
.flatten()
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
.collect();
[a[0], a[1], a[2], a[3], a[4]]
})
.collect();
(verts.len() == 4).then(|| Polygon {
normal: v["normals"][index].as_array().map_or_else(
|| {
// shortcut: collapsed legacy faces have no recoverable normal; re-prepare resources.
let p = verts
.iter()
.map(|v| glam::Vec3::new(v[0], v[1], v[2]))
.collect::<Vec<_>>();
(p[1] - p[0])
.cross(p[2] - p[0])
.normalize_or_zero()
.to_array()
},
|n| std::array::from_fn(|i| n[i].as_f64().unwrap_or(0.0) as f32),
),
vertices: [verts[0], verts[1], verts[2], verts[3]],
cull_back: false,
})
})
.collect();
let mut children: Vec<Part> = v["children"]
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| Part::parse(k, c))
.collect();
children.sort_by(|a, b| a.name.cmp(&b.name));
// A model whose root holds a part also named "root" (allay, vex,
// frog...): that inner part is what `root.getChild("root")` and the
// keyframe bones mean, so the outer one gives up the name.
let name = if name == "root" && children.iter().any(|c| c.name == "root") {
""
} else {
name
};
Self {
name: name.to_owned(),
pose: p,
polygons,
children,
}
}
}
/// Kept for callers of the earlier simple AI; species now select behaviour.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Brain {
@@ -1080,10 +873,10 @@ pub struct Attrs {
}
/// `minecraft:wolf_armor`'s maximum damage.
pub const WOLF_ARMOR_DURABILITY: i32 = 64;
/// Species-specific persistent state (variants, colours, taming, ageing...).
#[derive(Clone, Debug, Default)]
pub struct Extra {
pub client_side: bool, // `Level.isClientSide`: a client copy, which `Entity.hurt` leaves alone.
/// Saved fish/axolotl bucket flag; tadpoles save a constant true value.
pub from_bucket: bool,
/// Command-created armor instances, shared by commands and runtime damage.
@@ -1439,7 +1232,6 @@ impl Extra {
self.main_hand.take()
}
}
/// How far the boxes of a set of mobs reach from their positions: the widest reach sideways, below
/// and above among them, worked out from each mob's real box ([`Mob::bounds`]: a shulker's grows
/// toward its open lid, sideways or below its position). A box query over many mobs (a vehicle's,
@@ -2578,6 +2370,8 @@ pub struct Seen {
pub delay: i32,
/// `getLastHurtByMob` and its timestamp.
pub hurt_by: Option<(Who, i32)>,
/// `Entity.isInvisible`: the flag, or the invisibility effect.
pub invisible: bool,
/// `PathfinderMob.isPanicking`: a running panic goal or the brain's
/// `IS_PANICKING` memory.
pub panicking: bool,
@@ -2773,90 +2567,6 @@ impl Mob {
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
}
/// What other mobs see of this one this tick (`_player` is unused: it keyed the villager
/// reputation that nothing read). Inline: the game's snapshot of every mob writes each one in
/// its slot, not through a copy.
#[inline]
pub fn seen(&self, _player: Option<[i32; 4]>) -> Seen {
let m = self;
let villager = m.species() == Species::Villager;
Seen {
in_water: self.fluids.in_water,
playing_dead: axolotl::is_playing_dead(self),
last_damage: self.last_damage.map(|(d, _)| d),
id: m.id,
species: m.species(),
position: m.position,
bounds: m.bounds(),
hitbox: m.hitbox(),
eye_y: m.eye_y(),
alive: m.alive(),
baby: m.is_baby(),
// A villager is "in love" when it is willing to breed (`canBreed`).
in_love: if villager {
villager::can_breed(m)
} else {
m.extra.in_love > 0
},
tame: m.extra.tame,
bred: m.extra.bred,
owner: m.extra.owner,
hostile: m.is_enemy(),
target: m.ai.target,
health: m.health,
variant: m.extra.variant,
size: m.extra.size,
color: m.extra.color,
job: villager.then_some(m.extra.home).flatten(),
bed: villager.then_some(m.extra.bed).flatten(),
yaw: m.yaw,
velocity: m.velocity,
known_speed: m.position.subtract(m.previous),
leash: m.extra.leash,
caravan: caravan::head_of(m),
school: (m.extra.leader, m.extra.followers),
section: (m.extra.section, m.extra.section_seq),
delay: m.extra.ints[5],
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
panicking: m.ai.panicking,
active_raid: raider::has_active_raid(m),
opened_chest: m.extra.opened_chest,
passenger: m.extra.mount.is_some(),
vehicle: m.extra.mount,
pushable: m.pushable(),
sleeping: villager::sleeping(m)
|| (m.species() == Species::Fox && fox::flag(m, fox::SLEEPING)),
cold_strider: strider::suffocating(m),
category: m.category(),
potential_job: villager_brain::potential_job_site(m),
meeting: villager.then_some(m.extra.meeting).flatten(),
profession: m.extra.variant2,
xp: villager::xp(m),
villager: villager.then(|| Arc::new(villager::seen(m))),
stats: [
f64::from(m.attrs.max_health),
m.extra.vector[0],
m.attrs.speed,
],
sitting: m.extra.sitting_pose,
silent: m.silent,
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
is_vehicle: equine::is_vehicle(m),
still: m.species() == Species::HappyGhast && happy_ghast::on_still_timeout(m),
carrying: m.rider.is_some() || m.extra.rider.is_some(),
knockback_resistance: m.attrs.knockback_resistance,
effects: m
.extra
.effects
.iter()
.map(|e| e.0.trim_start_matches("minecraft:"))
.collect(),
hunted_recently: m.species() == Species::Piglin && m.extra.ints[0] > 0,
hurt_recently: m.species() == Species::Piglin && m.extra.piglin.hurt,
huntable: m.species() == Species::Hoglin && !m.is_baby() && !m.extra.cannot_be_hunted,
}
}
/// `EntityType.getCategory`'s name.
pub fn category(&self) -> &'static str {
match self.kind.category.as_str() {
@@ -3630,7 +3340,8 @@ impl Mob {
/// `LivingEntity.isInvulnerableTo` and dead or removed: only #bypasses_invulnerability gets
/// past the flag. The guard every damage hook runs after (`LivingIncomingDamageEvent`).
pub fn refuses_damage(&self, source: &Damage) -> bool {
!self.alive()
self.extra.client_side
|| !self.alive()
|| self.removed
|| (self.is_invulnerable() && !source.kind.bypasses_invulnerability())
}
@@ -5816,6 +5527,26 @@ mod armor_hurt_tests {
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
/// `Entity.hurt` on a client level does nothing: the horse a rider's
/// client drives takes no fall of its own, and the server's health stays
/// (#15294: the client's copy died and lay on the ground, still ridable).
#[test]
fn a_client_copy_takes_no_fall_damage() {
let mut m = horse();
m.extra.client_side = true;
let health = m.health;
let mut events = Vec::new();
assert!(!m.damage(20.0, Damage::of(DamageKind::Fall)));
m.cause_fall_damage_at(30.0, 1.0, &mut events, None, DamageKind::Fall);
assert_eq!(m.health, health);
assert_eq!(m.hurt_time, 0);
assert!(m.alive());
// The server's copy of the same fall is hurt.
let mut server = horse();
server.cause_fall_damage_at(30.0, 1.0, &mut events, None, DamageKind::Fall);
assert!(server.health < health);
}
/// `LivingEntity.hurtArmor` (`doHurtEquipment`): a hit that lands on a mob
/// wearing body armour leaves the piece owing a wear, which the host
/// applies with the item's own maximum and fire rules.
+3 -76
View File
@@ -33,7 +33,9 @@ mod wet_fire_tests;
mod air;
mod step;
mod walk_target;
pub(crate) use step::step_sound;
pub use walk_target::walk_target_value;
use crate::powder_snow::particle_velocity as powder_snow_particle_velocity;
@@ -1261,7 +1263,7 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
}
}
// Entity.checkBelowWorld: LivingEntity.onBelowWorld hurts 4 a tick.
if m.position.y < f64::from(w.world.bottom_y() - 64) {
if m.position.y < w.world.below_world_y() {
hurt(m, w, events, 4.0, Damage::of(DamageKind::OutOfWorld));
}
m.first_tick = false;
@@ -2551,81 +2553,6 @@ pub fn sound_volume(m: &Mob) -> f32 {
}
}
/// PathfinderMob.getWalkTargetValue (Animal and Monster overrides).
pub fn walk_target_value(m: &Mob, w: &MobWorld, p: [i32; 3]) -> f32 {
let light = || w.brightness(p[0], p[1], p[2]) - 0.5;
// Guardian.getWalkTargetValue: water first.
if family(m.species()) == Family::Guardian && w.fluid(p[0], p[1], p[2]) == Fluid::Water {
return 10.0 + light();
}
// HappyGhast.getWalkTargetValue: open air, best just above the ground.
if m.species() == Species::HappyGhast {
let air = |y: i32| w.facts.is_air(w.world.block(p[0], y, p[2]));
return if !air(p[1]) {
0.0
} else if air(p[1] - 1) && !air(p[1] - 2) {
10.0
} else {
5.0
};
}
if m.species() == Species::Strider {
return super::strider::walk_target_value(m, w, p);
}
if m.species() == Species::Turtle {
return super::turtle::walk_target_value(m, w, p, light());
}
// Hoglin.getWalkTargetValue: nowhere near a repellent, crimson nylium best.
if m.species() == Species::Hoglin {
let near = m.extra.repellent.is_some_and(|r| {
let d = |i: usize| i64::from(r[i] - p[i]);
d(0) * d(0) + d(1) * d(1) + d(2) * d(2) < 64
});
return if near {
-1.0
} else if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:crimson_nylium" {
10.0
} else {
0.0
};
}
// Bee.getWalkTargetValue: any air block.
if m.species() == Species::Bee {
return if w.facts.is_air(w.world.block(p[0], p[1], p[2])) {
10.0
} else {
0.0
};
}
// AbstractNautilus, Axolotl and Creaking.getWalkTargetValue: everywhere alike.
if matches!(
m.species(),
Species::Nautilus | Species::ZombieNautilus | Species::Axolotl | Species::Creaking
) {
return 0.0;
}
// Silverfish.getWalkTargetValue: a block sitting on an infested host is
// the best place to walk to, so it goes back into the stone.
if m.species() == Species::Silverfish
&& super::hostile::is_infested_host(w.block_name(p[0], p[1] - 1, p[2]))
{
return 10.0;
}
if m.hostile()
&& !matches!(
m.species(),
Species::Slime | Species::MagmaCube | Species::Ghast | Species::Phantom
)
{
return -light();
}
if m.kind.flags.animal {
if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:grass_block" {
return 10.0;
}
return light();
}
0.0
}
/// `Allay.hurtServer`'s liked-player guard for verified player sources.
/// Other species and unresolved callers retain their existing filters.
pub fn accepts_damage_from_player(
+3 -3
View File
@@ -5,10 +5,10 @@ use super::{Mob, Species, can_age_up};
/// that `canFallInLove` (not in love yet) or a baby that `canAgeUp` (not age
/// locked). Tamed wolves and cats only sit or stand when it does not.
pub fn takes_food(m: &Mob) -> bool {
// `Turtle.canFallInLove` is `super.canFallInLove() && !this.hasEgg()`: a
// turtle that already carries an egg refuses its food instead of breeding.
// `canFallInLove`: a turtle carrying an egg and a pacified hoglin refuse their food.
let breedable = m.extra.in_love <= 0
&& !(m.species() == Species::Turtle && crate::mobs::turtle::has_egg(m));
&& !(m.species() == Species::Turtle && crate::mobs::turtle::has_egg(m))
&& !crate::mobs::piglin::refuses_love(m);
// The growing branch is `canAgeUp()`, which asks `isBaby()`, not the raw
// age: a frog or a camel husk that `canBeABaby` says is never a baby keeps
// the food in the player's hand however negative its `Age` is.
@@ -0,0 +1,174 @@
//! `PathfinderMob.getWalkTargetValue` and the classes that override it: the score
//! a stroll gives a candidate position, of which it keeps the best of ten.
use super::{Family, Mob, MobWorld, Species, family};
use crate::blocks::Fluid;
pub fn walk_target_value(m: &Mob, w: &MobWorld, p: [i32; 3]) -> f32 {
let light = || w.brightness(p[0], p[1], p[2]) - 0.5;
// Guardian.getWalkTargetValue: water first.
if family(m.species()) == Family::Guardian && w.fluid(p[0], p[1], p[2]) == Fluid::Water {
return 10.0 + light();
}
// HappyGhast.getWalkTargetValue: open air, best just above the ground.
if m.species() == Species::HappyGhast {
let air = |y: i32| w.facts.is_air(w.world.block(p[0], y, p[2]));
return if !air(p[1]) {
0.0
} else if air(p[1] - 1) && !air(p[1] - 2) {
10.0
} else {
5.0
};
}
if m.species() == Species::Strider {
return crate::mobs::strider::walk_target_value(m, w, p);
}
if m.species() == Species::Turtle {
return crate::mobs::turtle::walk_target_value(m, w, p, light());
}
// Hoglin.getWalkTargetValue: nowhere near a repellent, crimson nylium best.
if m.species() == Species::Hoglin {
let near = m.extra.repellent.is_some_and(|r| {
let d = |i: usize| i64::from(r[i] - p[i]);
d(0) * d(0) + d(1) * d(1) + d(2) * d(2) < 64
});
return if near {
-1.0
} else if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:crimson_nylium" {
10.0
} else {
0.0
};
}
// Bee.getWalkTargetValue: any air block.
if m.species() == Species::Bee {
return if w.facts.is_air(w.world.block(p[0], p[1], p[2])) {
10.0
} else {
0.0
};
}
// AbstractNautilus, Axolotl and Creaking.getWalkTargetValue: everywhere alike.
if matches!(
m.species(),
Species::Nautilus | Species::ZombieNautilus | Species::Axolotl | Species::Creaking
) {
return 0.0;
}
// Silverfish.getWalkTargetValue: a block sitting on an infested host is
// the best place to walk to, so it goes back into the stone.
if m.species() == Species::Silverfish
&& crate::mobs::hostile::is_infested_host(w.block_name(p[0], p[1] - 1, p[2]))
{
return 10.0;
}
// Enderman.getWalkTargetValue, Pillager's and Warden's: every position scores
// the same, so their strolls pick one of the ten at random.
if matches!(
m.species(),
Species::Enderman | Species::Pillager | Species::Warden
) {
return 0.0;
}
if m.hostile()
&& !matches!(
m.species(),
Species::Slime | Species::MagmaCube | Species::Ghast | Species::Phantom
)
{
return -light();
}
// MushroomCow.getWalkTargetValue replaces Animal's: mycelium is the best
// ground and any other is scored by the light, grass or not.
if m.species() == Species::Mooshroom {
return if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:mycelium" {
10.0
} else {
w.brightness(p[0], p[1], p[2]) - 0.5
};
}
if m.kind.flags.animal {
if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:grass_block" {
return 10.0;
}
return light();
}
0.0
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// `PathfinderMob.getWalkTargetValue` and its overrides, at three positions
/// that differ only in the block below. A cow scores grass 10 and mycelium by
/// the light; a mushroom cow, whose own override replaces Animal's, mycelium
/// 10 and grass by the light; a monster the negated light; and an enderman, a
/// pillager and a warden the same 0 everywhere.
#[test]
fn a_stroll_scores_its_ground_the_way_the_jar_does() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|world, registry| {
let stone = registry.default_state("minecraft:stone").unwrap();
let grass = registry.default_state("minecraft:grass_block").unwrap();
let mycelium = registry.default_state("minecraft:mycelium").unwrap();
for cx in -1..1 {
for cz in -1..1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -4..4 {
for z in -4..4 {
world.set(x, 63, z, stone).unwrap();
}
}
world.set(-2, 63, 0, grass).unwrap();
world.set(0, 63, 0, stone).unwrap();
world.set(2, 63, 0, mycelium).unwrap();
},
|w, types| {
let value = |id: &str, x: i32| {
let kind = Arc::new(types.types[id].clone());
let m = Mob::new(1, kind, Vec3::ZERO, 0.0);
walk_target_value(&m, w, [x, 64, 0])
};
// The light term of the middle position, which the report quotes.
let light = w.brightness(0, 64, 0) - 0.5;
// A cow: grass 10, mycelium and stone by the light.
assert_eq!(value("minecraft:cow", -2), 10.0, "cow on grass");
assert_eq!(value("minecraft:cow", 0), light, "cow on stone");
assert_eq!(value("minecraft:cow", 2), light, "cow on mycelium");
// A mushroom cow: mycelium 10, grass by the light, not 10.
assert_eq!(
value("minecraft:mooshroom", 2),
10.0,
"mooshroom on mycelium"
);
assert_eq!(
value("minecraft:mooshroom", -2),
light,
"mooshroom on grass"
);
// A monster: the negated light, on grass like anywhere else.
assert_eq!(value("minecraft:zombie", -2), -light, "zombie");
// The three that score every position the same.
for flat in [
"minecraft:enderman",
"minecraft:pillager",
"minecraft:warden",
] {
assert_eq!(value(flat, -2), 0.0, "{flat} on grass");
assert_eq!(value(flat, 0), 0.0, "{flat} on stone");
assert_eq!(value(flat, 2), 0.0, "{flat} on mycelium");
}
},
);
// Without the extracted vanilla data the world cannot be built.
let _ = ran;
}
}
+4 -7
View File
@@ -2390,13 +2390,10 @@ impl Goal for FollowMob {
let area = m
.bounds()
.inflate(Vec3::new(self.area, self.area, self.area));
let found = cx
.w
.others_near(area)
.find(|s| {
s.species != species && s.bounds.intersects(area) && !s.has_effect("invisibility")
})
.map(|s| s.id);
let found =
cx.w.others_near(area)
.find(|s| s.species != species && s.bounds.intersects(area) && !s.invisible)
.map(|s| s.id);
self.following = found;
found.is_some()
}
+36 -1
View File
@@ -530,12 +530,16 @@ pub fn iron_anim(m: &Mob, s: &State, p: &mut Poses) {
}
pub fn register_snow(m: &mut Mob) {
// `new WaterAvoidingRandomStrollGoal(this, 1.0, 1.0000001E-5F)`: the probability of a plain
// stroll is the constructor's third argument, not the default 0.001.
let mut stroll = Stroll::avoiding_water(1.0);
stroll.water_avoiding = Some(1.000_000_1e-5);
let g = &mut m.ai.goals;
g.add(
1,
super::witch::RangedAttack::new(1.25, 20, 20, 10.0, snowball_attack),
);
g.add(2, Stroll::avoiding_water(1.0));
g.add(2, stroll);
g.add(3, LookAtPlayer::new(6.0));
g.add(4, RandomLookAround::default());
m.ai.targets.add(
@@ -1134,3 +1138,34 @@ mod crack_sound_tests {
assert_eq!(cracks(&mut m, 10.0), 1, "30 to 20 crosses 0.25");
}
}
#[cfg(test)]
mod snow_golem_stroll_tests {
use super::*;
use std::sync::Arc;
/// A snow golem of a made-up type: only its goals matter here.
fn snow_golem() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
Arc::make_mut(&mut kind).id = "minecraft:snow_golem".into();
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
/// `SnowGolem.registerGoals` adds `new WaterAvoidingRandomStrollGoal(this, 1.0, 1.0000001E-5F)`
/// (the bytecode's float constant): the chance of a plain stroll instead of a land one is
/// that, not the two-argument constructor's default 0.001.
#[test]
fn a_snow_golem_strolls_with_the_rare_probability() {
let mut m = snow_golem();
// The made-up type brings goals of its own.
m.ai.goals.clear();
register_snow(&mut m);
let goals = format!("{:?}", m.ai.goals);
let rare = format!("water_avoiding: {:?}", Some(1.000_000_1e-5_f32));
let default = format!("water_avoiding: {:?}", Some(0.001_f32));
assert!(goals.contains(&rare), "{goals}");
assert!(!goals.contains(&default), "{goals}");
// One stroll goal, not two.
assert_eq!(goals.matches("water_avoiding:").count(), 1, "{goals}");
}
}
@@ -0,0 +1,50 @@
//! `FollowMobGoal` skips an invisible mob: the flag or the invisibility effect.
use super::goals::{Cx, FollowMob};
use super::{Mob, Species};
use crate::mobs::LegacyRandom;
use crate::mobs::world::with_test_world;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// `FollowMobGoal.canUse` skips an invisible mob (`!isInvisible()`), which
/// is the flag or the invisibility effect.
#[test]
fn a_follow_mob_goal_ignores_an_invisible_mob() {
use crate::mobs::sensor_scan_tests::kind;
let mut outcomes = Vec::new();
if !with_test_world(&[], |w, _| {
for invisible in [false, true] {
let mut follower_kind = (*kind()).clone();
follower_kind.species = Species::Parrot;
let mut target_kind = (*kind()).clone();
target_kind.species = Species::Cow;
let mut m = Mob::new(1, Arc::new(follower_kind), Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut target = Mob::new(2, Arc::new(target_kind), Vec3::new(1.5, 64.0, 0.5), 0.0);
target.invisible = invisible;
let seen = [target.seen(None)];
let world = crate::mobs::MobWorld {
others: &seen,
..w.clone()
};
m.ai.goals.add(1, FollowMob::new(1.0, 3.0, 7.0));
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
outcomes.push(goals.is_running("FollowMobGoal"));
m.ai.goals = goals;
}
}) {
return;
}
assert_eq!(
outcomes,
vec![true, false],
"a visible mob is followed; an invisible one is not"
);
}
+30 -1
View File
@@ -100,6 +100,10 @@ pub struct Fluids {
/// The mod fluid the mob is deepest in (`isInFluidType`): not water to the
/// game, but the mob moves in it as in water, with its numbers.
pub modded: Option<ModFluidImmersion>,
/// The boat `positionRider` last seated the mob in, by id, and what
/// `AbstractBoat.modifyPassengerFluidInteractionBox` reads of it. Kept
/// across `update_fluids`; good while the mob still rides that boat.
pub boat: Option<(u32, crate::motion::RiddenBoat)>,
}
/// `FluidFlowWorld` over the mob's scene: the neighbours a flow vector reads.
struct FlowScene<'a, 'w>(&'a MobWorld<'w>);
@@ -1061,11 +1065,36 @@ impl Mob {
speed: f64::from(0.15_f32),
});
}
/// `Entity.getFluidInteractionBox` and the fluids in it: the box less 0.001,
/// then the ridden boat's say (`AbstractBoat.modifyPassengerFluidInteractionBox`).
/// A boat that isn't underwater keeps only what lies above its top, and
/// all of a passenger it reaches the head of, so a mob sitting in a boat
/// on a lake is neither in the water (it burns in daylight, isn't pushed
/// by the flow) nor splashes.
fn fluid_interaction(&self, w: &MobWorld, boat: Option<crate::motion::RiddenBoat>) -> Fluids {
let b = self.bounds();
let Some(boat) = boat.filter(|boat| !boat.under_water) else {
return w.fluids(b, self.eye_y());
};
// `fluids` deflates the box by 0.001 itself: the deflated top against
// the boat's, and the deflated bottom raised to it, as vanilla does.
let (lo, hi) = (b.min_position(), b.max_position());
if boat.max_y >= hi.y - 0.001 {
return Fluids::default();
}
let lo = Vec3::new(lo.x, lo.y.max(boat.max_y - 0.001), lo.z);
w.fluids(Aabb::new(lo, hi), self.eye_y())
}
/// Entity.updateFluidInteraction: the fluid state, then touching water
/// resets the fall and entering it (after the first tick) splashes.
pub(super) fn update_fluids(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
let was_touching_water = self.fluids.in_water;
self.fluids = w.fluids(self.bounds(), self.eye_y());
let boat = self
.fluids
.boat
.filter(|(id, _)| self.extra.mount == Some(*id));
self.fluids = self.fluid_interaction(w, boat.map(|(_, b)| b));
self.fluids.boat = boat;
// EntityFluidInteraction.applyCurrentTo: the flow pushes the mob.
if (self.fluids.in_water || self.fluids.in_lava) && self.pushed_by_fluid() {
self.velocity = w.pushed_by_currents(self.bounds(), self.velocity);
@@ -1179,6 +1179,64 @@ fn a_steered_mount_moves_through_the_air_at_a_tenth_of_its_speed() {
assert_eq!(m.flying_speed(), 0.3_f32 * 0.1);
}
/// `Entity.getFluidInteractionBox` with `AbstractBoat.modifyPassengerFluidInteractionBox`:
/// a drowned sitting in a boat on a lake, its legs below the water's surface,
/// isn't in the water (so it burns in daylight), while the same drowned
/// without the boat, in a boat that sank, or with a boat it has since left, is.
/// A boat reaching above its head keeps all fluids off it.
#[test]
fn a_mob_in_a_boat_on_water_is_not_in_the_water() {
let build = |world: &mut crate::world::World,
registry: &horizon_world::registry::BlockRegistry| {
world.chunks.insert(
(0, 0),
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
);
let water = registry.default_state("minecraft:water").unwrap();
for x in 0..=4 {
for z in 0..=4 {
// Sources up to y 3: the surface is at 3 + 8/9.
for y in 1..=3 {
world.set(x, y, z, water).unwrap();
}
}
}
};
let boat = |under_water, max_y| crate::motion::RiddenBoat { under_water, max_y };
crate::mobs::world::with_built_test_world(&[], build, |w, types| {
let kind = std::sync::Arc::new(types.types["minecraft:drowned"].clone());
let wet = |mount: Option<u32>, ridden: Option<crate::motion::RiddenBoat>| {
let mut m = Mob::new(1, kind.clone(), Vec3::new(2.5, 3.5, 2.5), 0.0);
m.extra.mount = mount;
m.fluids.boat = ridden.map(|b| (7, b));
m.update_fluids(w, &mut Vec::new());
(m.fluids.in_water, m.fluids.water)
};
assert!(
wet(None, None).0,
"its legs are in the water without a boat"
);
assert_eq!(
wet(Some(7), Some(boat(false, 3.95))),
(false, 0.0),
"a boat afloat keeps only what is above its top, clear of the water"
);
assert_eq!(
wet(Some(7), Some(boat(false, 6.0))),
(false, 0.0),
"a boat reaching above its head keeps all of it out"
);
assert!(
wet(Some(7), Some(boat(true, 3.95))).0,
"a boat under water shelters nothing"
);
assert!(
wet(None, Some(boat(false, 3.95))).0,
"a boat it no longer rides shelters nothing"
);
});
}
/// `Strider.checkFallDamage`: in lava the fall is reset before the base runs, so
/// a strider that drops into a lava sea lands with no damage and no dust — and
/// walks out owing nothing — while a pig in the same lava takes the fall, and a
+219
View File
@@ -0,0 +1,219 @@
//! A mob's baked model: the parts of `entity-models.json` as polygons, and the normal stack that packs their
//! normals the way `ModelPart` does.
use serde_json::Value;
#[derive(Clone, Debug)]
pub struct Polygon {
pub normal: [f32; 3],
pub vertices: [[f32; 5]; 4],
/// The model's render type culls back faces (`entityCutoutCull`: the bat,
/// the baby turtle).
pub cull_back: bool,
}
impl Polygon {
pub(super) fn vertex_normal(&self, pose: ModelNormals) -> [i8; 4] {
let n = pose.matrix * glam::Vec3::from_array(self.normal);
let n = if pose.trusted {
n
} else {
n.normalize_or_zero()
};
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
let w = if self.cull_back {
crate::mesher::SHADED_CULL_W
} else {
-127
};
[packed[0], packed[1], packed[2], w]
}
}
/// ModelPart uses a separate normal stack: uniform scaling keeps normal lengths,
/// while nonuniform scaling requires normalization after the complete part transform.
#[derive(Clone, Copy)]
pub(super) struct ModelNormals {
pub(super) matrix: glam::Mat3,
pub(super) trusted: bool,
}
impl ModelNormals {
// shortcut: infer the root normal pose from its geometry matrix; pass an explicit
// root normal stack when root-transform rounding is ported as well.
pub(super) fn new(base: glam::Mat4) -> Self {
let m = glam::Mat3::from_mat4(base);
let lengths = glam::Vec3::new(m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
let uniform =
lengths.min_element() > 0.0 && lengths.max_element() - lengths.min_element() < 1.0e-6;
Self {
matrix: if uniform {
m / lengths.x
} else {
m.inverse().transpose()
},
trusted: uniform,
}
}
pub(super) fn rotation(x: f32, y: f32, z: f32) -> glam::Mat3 {
// JOML derives cosine from the rounded sine, then retains quaternion length.
// Assuming an exactly unit quaternion changes a packed normal by one byte.
let sin_cos = |angle: f32| {
let angle = angle * 0.5;
let sin = f64::from(angle).sin() as f32;
let cos = (1.0 - sin * sin).sqrt();
let a = angle + std::f32::consts::FRAC_PI_2;
let tau = std::f32::consts::TAU;
let mut quadrant = a - (a / tau) as i32 as f32 * tau;
if quadrant < 0.0 {
quadrant += tau;
}
(
sin,
if quadrant >= std::f32::consts::PI {
-cos
} else {
cos
},
)
};
let (sx, cx) = sin_cos(x);
let (sy, cy) = sin_cos(y);
let (sz, cz) = sin_cos(z);
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
let (w, x, y, z) = (
cx * cycz + sx * sysz,
sx * cycz - cx * sysz,
cx * sycz + sx * cysz,
cx * cysz - sx * sycz,
);
let (ww, xx, yy, zz) = (w * w, x * x, y * y, z * z);
let (xy, xz, xw, yz, yw, zw) = (x * y, x * z, x * w, y * z, y * w, z * w);
glam::Mat3::from_cols_array(&[
ww + xx - zz - yy,
(xy + xy) + (zw + zw),
(xz + xz) - (yw + yw),
(xy + xy) - (zw + zw),
yy - zz + ww - xx,
(yz + yz) + (xw + xw),
(yw + yw) + (xz + xz),
(yz + yz) - (xw + xw),
zz - yy - xx + ww,
])
}
pub(super) fn part(mut self, pose: [f32; 9]) -> Self {
self.matrix *= Self::rotation(pose[3], pose[4], pose[5]);
let scale = glam::Vec3::new(pose[6], pose[7], pose[8]);
if scale.x.abs() == scale.y.abs() && scale.x.abs() == scale.z.abs() {
if scale.min_element() < 0.0 {
self.matrix *= glam::Mat3::from_diagonal(scale.signum());
}
} else {
self.matrix *= glam::Mat3::from_diagonal(scale.recip());
self.trusted = false;
}
self
}
pub(super) fn part_rotated(mut self, pose: [f32; 9], rotation: glam::Mat3) -> Self {
self.matrix *= rotation;
let scale = glam::Vec3::new(pose[6], pose[7], pose[8]);
if scale.x.abs() == scale.y.abs() && scale.x.abs() == scale.z.abs() {
if scale.min_element() < 0.0 {
self.matrix *= glam::Mat3::from_diagonal(scale.signum());
}
} else {
self.matrix *= glam::Mat3::from_diagonal(scale.recip());
self.trusted = false;
}
self
}
}
#[derive(Clone, Debug)]
pub struct Part {
pub name: String,
/// x, y, z, xRot, yRot, zRot, xScale, yScale, zScale
pub pose: [f32; 9],
pub polygons: Vec<Polygon>,
pub children: Vec<Part>,
}
impl Part {
/// Marks every polygon as drawn by a back-face-culling render type.
pub(super) fn set_cull_back(&mut self) {
for poly in &mut self.polygons {
poly.cull_back = true;
}
for child in &mut self.children {
child.set_cull_back();
}
}
pub(super) fn parse(name: &str, v: &Value) -> Self {
let pose: Vec<f32> = v["pose"]
.as_array()
.into_iter()
.flatten()
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
.collect();
let mut p = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
for (i, x) in pose.iter().enumerate().take(9) {
p[i] = *x;
}
let polygons: Vec<Polygon> = v["polygons"]
.as_array()
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, poly)| {
let verts: Vec<[f32; 5]> = poly
.as_array()?
.iter()
.map(|vx| {
let a: Vec<f32> = vx
.as_array()
.into_iter()
.flatten()
.map(|x| x.as_f64().unwrap_or(0.0) as f32)
.collect();
[a[0], a[1], a[2], a[3], a[4]]
})
.collect();
(verts.len() == 4).then(|| Polygon {
normal: v["normals"][index].as_array().map_or_else(
|| {
// shortcut: collapsed legacy faces have no recoverable normal; re-prepare resources.
let p = verts
.iter()
.map(|v| glam::Vec3::new(v[0], v[1], v[2]))
.collect::<Vec<_>>();
(p[1] - p[0])
.cross(p[2] - p[0])
.normalize_or_zero()
.to_array()
},
|n| std::array::from_fn(|i| n[i].as_f64().unwrap_or(0.0) as f32),
),
vertices: [verts[0], verts[1], verts[2], verts[3]],
cull_back: false,
})
})
.collect();
let mut children: Vec<Part> = v["children"]
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| Part::parse(k, c))
.collect();
children.sort_by(|a, b| a.name.cmp(&b.name));
// A model whose root holds a part also named "root" (allay, vex,
// frog...): that inner part is what `root.getChild("root")` and the
// keyframe bones mean, so the outer one gives up the name.
let name = if name == "root" && children.iter().any(|c| c.name == "root") {
""
} else {
name
};
Self {
name: name.to_owned(),
pose: p,
polygons,
children,
}
}
}
+157 -4
View File
@@ -1516,7 +1516,7 @@ pub fn register_hoglin(m: &mut Mob) {
m.ai.targets.add(
2,
NearestTarget::player(true).when(|m, _| {
!m.is_baby() && m.extra.ints[0] <= 0 && m.extra.piglin.activity != Activity::Avoid
!m.is_baby() && !is_pacified(m) && m.extra.piglin.activity != Activity::Avoid
}),
);
}
@@ -1787,6 +1787,19 @@ fn find_hoglin_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> {
];
w.nearest_block(at, 8, 4, |_, name| tag.contains(name))
}
/// `HoglinAi.isPacified`: the `PACIFIED` memory (`ints[0]`) is present.
/// `BecomePassiveIfMemoryPresent` sets it for 200 ticks while a
/// `#hoglin_repellents` block is in `NEAREST_REPELLENT`.
pub fn is_pacified(m: &Mob) -> bool {
m.extra.ints[0] > 0
}
/// `Hoglin.canFallInLove` is `!HoglinAi.isPacified(this) && super.canFallInLove()`:
/// a warped fungus nearby pacifies the adult, and it refuses the fungus in the
/// player's hand. `ai::takes_food`'s baby branch asks `canAgeUp`, not
/// `canFallInLove`, so a pacified baby still grows.
pub fn refuses_love(m: &Mob) -> bool {
m.species() == Species::Hoglin && is_pacified(m)
}
/// The hoglin's repellent memories: the sensors (every 20 ticks), the end of
/// `PACIFIED` (`ints[0]`) and `BecomePassiveIfMemoryPresent(NEAREST_REPELLENT, 200)`.
fn hoglin_repellent(m: &mut Mob, w: &MobWorld) {
@@ -1798,9 +1811,7 @@ fn hoglin_repellent(m: &mut Mob, w: &MobWorld) {
hoglin_sense(m, w);
}
// `BecomePassiveIfMemoryPresent` is in the idle and fight activities.
if m.extra.ints[0] <= 0
&& m.extra.repellent.is_some()
&& m.extra.piglin.activity != Activity::Avoid
if !is_pacified(m) && m.extra.repellent.is_some() && m.extra.piglin.activity != Activity::Avoid
{
m.extra.ints[0] = 200;
m.ai.target = None;
@@ -2773,3 +2784,145 @@ mod brute_home_tests {
assert_eq!(piglin.extra.off_hand.as_deref(), Some("minecraft:shield"));
}
}
#[cfg(test)]
mod hoglin_pacified_feeding_tests {
use super::*;
use crate::mobs::ai::{feed_animal, takes_food};
use crate::mobs::sensor_scan_tests::kind;
use crate::mobs::world::with_built_test_world;
use horizon_world::registry::BlockRegistry;
use std::sync::Arc;
/// A stone floor under the origin and, at `(3, 0, 0)`, a warped fungus
/// (one of `#hoglin_repellents`) while `repellent` is set.
fn build(repellent: bool) -> impl FnOnce(&mut crate::world::World, &BlockRegistry) {
move |world, registry| {
let stone = registry.default_state("minecraft:stone").unwrap();
for cx in -4..4 {
for cz in -4..4 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -64..64 {
for z in -64..64 {
world.set(x, -1, z, stone).unwrap();
}
}
if repellent {
let fungus = registry.default_state("minecraft:warped_fungus").unwrap();
world.set(3, 0, 0, fungus).unwrap();
}
}
}
/// A hoglin of `age` on the floor at the origin.
fn hoglin(age: i32) -> Mob {
let mut kind = kind();
Arc::make_mut(&mut kind).species = Species::Hoglin;
let mut m = Mob::new(20, kind, Vec3::new(0.5, 0.0, 0.5), 0.0);
m.extra.age = age;
m
}
/// `HoglinAi.isPacified`: the `PACIFIED` memory, which the hoglin keeps in
/// `extra.ints[0]`.
fn pacified(m: &Mob) -> bool {
m.extra.ints[0] > 0
}
/// Ticks the hoglin's per-tick piglin state `n` times (`ai_step` calls
/// `hoglin_repellent` for a live hoglin): the sensor every 20 ticks, the
/// `PACIFIED` countdown every tick. Twenty ticks always hold one sensor
/// tick.
fn tick(m: &mut Mob, w: &MobWorld, n: usize) {
for _ in 0..n {
m.age += 1;
hoglin_repellent(m, w);
}
}
/// Without any world (CI has no vanilla data): the `PACIFIED` memory alone
/// makes an adult refuse its food, and a baby still grows.
#[test]
fn the_pacified_memory_alone_refuses_an_adult_and_lets_a_baby_grow() {
let mut level = LegacyRandom::new(0);
let mut adult = hoglin(0);
assert!(takes_food(&adult), "an unpacified adult breeds");
adult.extra.ints[0] = 200;
assert!(!takes_food(&adult), "a pacified adult refuses its food");
assert!(feed_animal(&mut adult, &mut level, None).is_none());
assert_eq!(adult.extra.in_love, 0);
let mut baby = hoglin(-24000);
baby.extra.ints[0] = 200;
assert!(feed_animal(&mut baby, &mut level, None).is_some());
assert_eq!(baby.extra.age, -24000 + 120 * 20, "a tenth of the growth");
}
/// `Hoglin.canFallInLove` is
/// `!HoglinAi.isPacified(this) && super.canFallInLove()`, so
/// `Animal.mobInteract` refuses the crimson fungus of an
/// adult pacified by a nearby repellent: no food used, no love, no baby.
/// The baby's branch asks `canAgeUp`, not `canFallInLove`, so a pacified
/// baby still grows up, and no other species reads the hoglin's memory.
#[test]
fn a_pacified_hoglin_refuses_breeding_food_and_a_baby_still_grows() {
let mut level = LegacyRandom::new(0);
let ran = with_built_test_world(&[], build(true), |w, _| {
let mut adult = hoglin(0);
assert!(!pacified(&adult), "no repellent memory yet");
assert!(takes_food(&adult), "an adult away from fungus breeds");
tick(&mut adult, w, 20);
assert_eq!(adult.extra.repellent, Some([3, 0, 0]), "the sensor sees it");
assert!(pacified(&adult), "and the hoglin turns passive");
assert!(!takes_food(&adult), "a pacified adult refuses its food");
assert!(
feed_animal(&mut adult, &mut level, None).is_none(),
"so the fungus is not eaten"
);
assert_eq!(adult.extra.in_love, 0, "and it does not fall in love");
let mut baby = hoglin(-24000);
tick(&mut baby, w, 20);
assert!(pacified(&baby), "the baby is pacified too");
assert!(takes_food(&baby), "but it still takes food to grow");
assert!(feed_animal(&mut baby, &mut level, None).is_some());
assert_eq!(baby.extra.age, -24000 + 120 * 20, "a tenth of the growth");
assert_eq!(baby.extra.in_love, 0, "a baby never falls in love");
let mut cow = kind();
Arc::make_mut(&mut cow).id = "minecraft:cow".into();
let mut cow = Mob::new(21, cow, Vec3::new(0.5, 0.0, 0.5), 0.0);
cow.extra.ints[0] = 200;
assert!(takes_food(&cow), "another species ignores the memory");
});
let _ = ran;
}
/// `BecomePassiveIfMemoryPresent.create(NEAREST_REPELLENT, 200)` sets
/// `PACIFIED` for 200 ticks while the sensor holds the repellent, and the
/// sensor drops it as soon as no repellent is in reach: walking away from
/// the fungus keeps the adult refusing food for the rest of the 200 ticks,
/// then it breeds again (`HoglinAi.wasHurtBy` is the other way out).
#[test]
fn pacification_outlives_the_repellent_by_200_ticks() {
let mut level = LegacyRandom::new(0);
let ran = with_built_test_world(&[], build(true), |w, _| {
let mut m = hoglin(0);
tick(&mut m, w, 20);
assert!(pacified(&m));
// Out of `HoglinSpecificSensor`'s reach (8 sideways, 4 up or down).
m.position = Vec3::new(40.5, 0.0, 0.5);
tick(&mut m, w, 20);
assert_eq!(m.extra.repellent, None, "the sensor lost the fungus");
assert!(pacified(&m), "but PACIFIED still has 180 ticks left");
assert!(!takes_food(&m));
assert!(feed_animal(&mut m, &mut level, None).is_none());
assert_eq!(m.extra.in_love, 0);
tick(&mut m, w, 180);
assert!(!pacified(&m), "PACIFIED expires after 200 ticks");
assert!(takes_food(&m), "the adult breeds again");
assert!(feed_animal(&mut m, &mut level, None).is_some());
assert_eq!(m.extra.in_love, 600);
});
let _ = ran;
}
}
+95
View File
@@ -0,0 +1,95 @@
//! `Mob::seen`: the per-tick snapshot goals read (`game` writes one per mob).
use super::*;
impl Mob {
/// What other mobs see of this one this tick (`_player` is unused: it keyed the villager
/// reputation that nothing read). Inline: the game's snapshot of every mob writes each one in
/// its slot, not through a copy.
#[inline]
pub fn seen(&self, _player: Option<[i32; 4]>) -> Seen {
let m = self;
let villager = m.species() == Species::Villager;
Seen {
in_water: self.fluids.in_water,
playing_dead: axolotl::is_playing_dead(self),
last_damage: self.last_damage.map(|(d, _)| d),
id: m.id,
species: m.species(),
position: m.position,
bounds: m.bounds(),
hitbox: m.hitbox(),
eye_y: m.eye_y(),
alive: m.alive(),
baby: m.is_baby(),
// A villager is "in love" when it is willing to breed (`canBreed`).
in_love: if villager {
villager::can_breed(m)
} else {
m.extra.in_love > 0
},
tame: m.extra.tame,
bred: m.extra.bred,
owner: m.extra.owner,
hostile: m.is_enemy(),
target: m.ai.target,
health: m.health,
variant: m.extra.variant,
size: m.extra.size,
color: m.extra.color,
job: villager.then_some(m.extra.home).flatten(),
bed: villager.then_some(m.extra.bed).flatten(),
yaw: m.yaw,
velocity: m.velocity,
known_speed: m.position.subtract(m.previous),
leash: m.extra.leash,
caravan: caravan::head_of(m),
school: (m.extra.leader, m.extra.followers),
section: (m.extra.section, m.extra.section_seq),
delay: m.extra.ints[5],
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
invisible: m.invisible
|| m.extra
.effects
.iter()
.any(|e| e.0 == "minecraft:invisibility"),
panicking: m.ai.panicking,
active_raid: raider::has_active_raid(m),
opened_chest: m.extra.opened_chest,
passenger: m.extra.mount.is_some(),
vehicle: m.extra.mount,
pushable: m.pushable(),
sleeping: villager::sleeping(m)
|| (m.species() == Species::Fox && fox::flag(m, fox::SLEEPING)),
cold_strider: strider::suffocating(m),
category: m.category(),
potential_job: villager_brain::potential_job_site(m),
meeting: villager.then_some(m.extra.meeting).flatten(),
profession: m.extra.variant2,
xp: villager::xp(m),
villager: villager.then(|| Arc::new(villager::seen(m))),
stats: [
f64::from(m.attrs.max_health),
m.extra.vector[0],
m.attrs.speed,
],
sitting: m.extra.sitting_pose,
silent: m.silent,
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
is_vehicle: equine::is_vehicle(m),
still: m.species() == Species::HappyGhast && happy_ghast::on_still_timeout(m),
carrying: m.rider.is_some() || m.extra.rider.is_some(),
knockback_resistance: m.attrs.knockback_resistance,
effects: m
.extra
.effects
.iter()
.map(|e| e.0.trim_start_matches("minecraft:"))
.collect(),
hunted_recently: m.species() == Species::Piglin && m.extra.ints[0] > 0,
hurt_recently: m.species() == Species::Piglin && m.extra.piglin.hurt,
huntable: m.species() == Species::Hoglin && !m.is_baby() && !m.extra.cannot_be_hunted,
}
}
}
+90 -2
View File
@@ -1033,8 +1033,12 @@ pub fn register_cat(m: &mut Mob) {
// in insertion order, so it comes last.
g.add(
4,
super::goals::AvoidEntity::new(super::goals::Avoid::Player, 16.0, 0.8, 1.33)
.when(|m| !m.extra.tame),
CatAvoid(super::goals::AvoidEntity::new(
super::goals::Avoid::Player,
16.0,
0.8,
1.33,
)),
);
m.ai.targets.add(
1,
@@ -1239,6 +1243,30 @@ fn empty_above(w: &MobWorld, p: [i32; 3]) -> bool {
pub fn is_owner_of(m: &Mob, p: &super::PlayerView) -> bool {
m.extra.owner.is_none_or(|o| o == p.uuid)
}
/// `Cat.CatAvoidEntityGoal`: the player avoidance only while untamed.
#[derive(Clone, Debug)]
struct CatAvoid(super::goals::AvoidEntity);
impl Goal for CatAvoid {
goal_clone!();
fn flags(&self) -> u8 {
self.0.flags()
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
!m.extra.tame && self.0.can_use(m, cx)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
!m.extra.tame && self.0.can_continue(m, cx)
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.start(m, cx)
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.stop(m, cx)
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.tick(m, cx)
}
}
/// `Cat.CatRelaxOnOwnerGoal`: while the owner sleeps, the cat lies down beside
/// them, and may leave a morning gift when they wake up.
#[derive(Clone, Debug, Default)]
@@ -2301,6 +2329,66 @@ mod tests {
"an alive attacker is avenged; a dead one is not"
);
}
/// `Cat.CatAvoidEntityGoal.canContinueToUse` is `!cat.isTame()
/// && super.canContinueToUse()`: taming a fleeing cat stops the flight.
#[test]
fn a_cat_stops_fleeing_when_it_is_tamed() {
use crate::mobs::goals::Cx;
use crate::mobs::path::{Path, PathNode, PathType};
use crate::mobs::world::with_test_world;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
let mut outcomes = Vec::new();
if !with_test_world(&[], |w, _| {
for tame in [false, true] {
let mut kind = (*kind()).clone();
kind.species = Species::Cat;
let mut m = Mob::new(1, Arc::new(kind), Vec3::new(0.5, 65.0, 0.5), 0.0);
m.extra.tame = tame;
m.ai.nav.path = Some(Path {
nodes: vec![
PathNode {
x: 0,
y: 65,
z: 0,
kind: PathType::Walkable,
},
PathNode {
x: 2,
y: 65,
z: 0,
kind: PathType::Walkable,
},
],
next: 0,
target: [2, 65, 0],
dist_to_target: 0.0,
reached: true,
});
let mut goal = CatAvoid(super::super::goals::AvoidEntity::new(
super::super::goals::Avoid::Player,
16.0,
0.8,
1.33,
));
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
outcomes.push(goal.can_continue(&mut m, &mut cx));
}
}) {
return;
}
assert_eq!(
outcomes,
vec![true, false],
"an untamed cat flees; a tamed one stops"
);
}
}
/// `NeutralMob.isAngryAtAllPlayers`: with the `universal_anger` game rule on, a
@@ -129,7 +129,7 @@ impl MotionWorld<'_, '_, '_> {
/// take too) and suffocation (`isInWall`, which the caller decides from
/// the blocks at the eyes).
pub fn server_hazards_tick(&mut self, in_wall: bool) -> Result<()> {
if self.player.position.y < f64::from(self.scene.bottom_y() - 64) {
if self.player.position.y < self.scene.below_world_y() {
let id = self
.health
.damage
@@ -397,3 +397,67 @@ mod fire_ignition_tests {
assert_eq!(ignite_contact(ticks, true, &mut r, 0.4), 64);
}
}
#[cfg(test)]
mod below_world_tests {
use super::*;
use crate::chunk::LevelShape;
/// The player's health after one `server_hazards_tick` at `y` in a level of `shape`.
fn health_after_hazards(shape: LevelShape, y: f64) -> f32 {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
scene.shape = shape;
let mut player = PlayerMotion::new(&resources, Vec3::new(0.5, y, 0.5)).unwrap();
MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
}
.server_hazards_tick(false)
.unwrap();
player.health.health()
}
/// `Entity.checkBelowWorld` hurts below `Level.getMinY() - 64`: y = -64 in the End and the
/// Nether (min Y 0), y = -128 in the Overworld, whatever the chunks' widened column holds.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_void_starts_64_blocks_below_each_levels_floor() {
assert_eq!(
health_after_hazards(LevelShape::END, -64.5),
16.0,
"End: below -64"
);
assert_eq!(
health_after_hazards(LevelShape::NETHER, -64.5),
16.0,
"Nether: below -64"
);
assert_eq!(
health_after_hazards(LevelShape::END, -63.5),
20.0,
"End: above -64"
);
assert_eq!(
health_after_hazards(LevelShape::OVERWORLD, -64.5),
20.0,
"Overworld: above -128"
);
assert_eq!(
health_after_hazards(LevelShape::OVERWORLD, -128.5),
16.0,
"Overworld: below -128"
);
}
}
+4
View File
@@ -1651,6 +1651,8 @@ impl Screen {
},
Some(50),
) => {
// Held through the save: a ping storing an icon meanwhile would undo the edit.
let guard = servers::lock();
let (mut list, mut hidden) = servers::load_all(&servers_file(saves));
let mut entry = ServerEntry::new(name.trim(), address.trim());
entry.accept_textures = *packs;
@@ -1674,6 +1676,8 @@ impl Screen {
},
}
let _ = servers::save_all(&servers_file(saves), &list, &hidden);
// Before the list is shown again: loading it may save, which takes the lock.
drop(guard);
*self = Self::list(saves);
}
(
+13 -5
View File
@@ -333,6 +333,11 @@ pub trait ProjectileWorld {
velocity
}
fn targets(&self) -> &[(Who, Aabb)];
/// `Level.getMinY() - 64` (`Entity.checkBelowWorld`); the Overworld's
/// unless the world says otherwise.
fn below_world_y(&self) -> f64 {
-128.0
}
/// Server-only mod-block contacts along the completed movement.
fn block_contacts(&self, _entity: i32, _kind: &str, _from: Vec3, _to: Vec3, _bounds: Aabb) {}
/// `Level.isEmptyBlock`.
@@ -1223,7 +1228,7 @@ impl Projectile {
}
}
}
(self.position.y > -128.0, Hit::None)
(self.position.y >= w.below_world_y(), Hit::None)
}
/// AbstractHurtingProjectile.applyInertia, on both server and client.
pub fn apply_hurting_inertia(&mut self, water: bool) {
@@ -1569,7 +1574,7 @@ impl Projectile {
{
self.position = at;
self.touching_water = w.in_water(self.bounds());
let alive = self.position.y > -128.0 && self.age < 1200;
let alive = self.position.y >= w.below_world_y() && self.age < 1200;
return (alive, Hit::None);
}
// LlamaSpit never discards on an entity: `onHitEntity` only hurts, so
@@ -1617,7 +1622,10 @@ impl Projectile {
self.velocity.y * drag - self.get_gravity(),
self.velocity.z * drag,
);
return (self.position.y > -128.0 && self.age < 6000, spit_hit);
return (
self.position.y >= w.below_world_y() && self.age < 6000,
spit_hit,
);
}
self.position = to;
// Fireballs ease towards the motion (`ProjectileUtil.rotateTowardsMovement`);
@@ -1632,8 +1640,8 @@ impl Projectile {
// it touches now, and their currents push it.
self.touching_water = w.in_water(self.bounds());
self.velocity = w.pushed_by_currents(self.bounds(), self.velocity);
let alive =
self.position.y > -128.0 && self.age < if self.is_fireball() { 1200 } else { 6000 };
let alive = self.position.y >= w.below_world_y()
&& self.age < if self.is_fireball() { 1200 } else { 6000 };
(alive, Hit::None)
}
/// AbstractArrow.onHitEntity damage: ceil(speed * base) plus crit bonus.
@@ -504,6 +504,11 @@ fn run(open: &mut Open, ctx: &Ctx<'_>, only_changed: bool, trigger: Trigger) ->
}
impl ScreenNarrator {
/// `Minecraft.getLastInputType`: widgets also use it for focused tooltips.
pub fn input_type(&self) -> InputType {
self.input
}
fn now(&self) -> i64 {
self.clock
.unwrap_or_else(|| self.epoch.elapsed().as_millis() as i64)
+3 -23
View File
@@ -24,6 +24,7 @@ mod flat;
mod onboarding;
#[cfg(test)]
mod tests;
mod world_folder;
pub mod world_management;
pub use buffet::State as BuffetEditor;
pub use create_world::{DIFFICULTIES, WorldOptions};
@@ -1849,29 +1850,8 @@ impl Title {
}
/// The folder a new world named `name` gets (`findResultFolder`).
fn target_folder(&self, name: &str) -> String {
let base: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let base = if base.trim().is_empty() {
"World".to_owned()
} else {
base.trim().to_owned()
};
// FileUtil.findAvailableName: append (n) until unused.
let mut folder = base.clone();
let mut n = 1;
while self.saves.join(&folder).exists() {
folder = format!("{base} ({n})");
n += 1;
}
folder
let new_world = lang::get_or("selectWorld.newWorld", "New World");
world_folder::result_folder(name, &new_world, |f| self.saves.join(f).exists())
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
// The game rules draw and press their own.
@@ -0,0 +1,132 @@
//! The folder a new world gets: `WorldCreationUiState.findResultFolder` through
//! `FileUtil.findAvailableName`. Lengths are Java's, in UTF-16 units.
/// `SharedConstants.ILLEGAL_FILE_CHARACTERS`, in its order.
const ILLEGAL_FILE_CHARACTERS: [char; 15] = [
'/', '\n', '\r', '\t', '\0', '\u{c}', '`', '?', '*', '\\', '<', '>', '|', '"', ':',
];
/// `FileUtil.MAX_FILE_NAME`.
const MAX_FILE_NAME: usize = 255;
/// The names `FileUtil.RESERVED_WINDOWS_FILENAMES` refuses once `sanitizeName` has turned every
/// dot into `_` (its `.*\.` and `(?:\..*)?` parts can no longer match).
const RESERVED: [&str; 24] = [
"COM", "CLOCK$", "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6",
"COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// `findResultFolder(name)`: the name trimmed as `String.trim` does, `new_world` (the translated
/// `selectWorld.newWorld`) when that leaves nothing, made free of the folders `taken` says exist.
/// A name whose count can't be read (`Mine ()`) falls back to `World`, as the method's catch does.
pub(super) fn result_folder(name: &str, new_world: &str, taken: impl Fn(&str) -> bool) -> String {
let name = name.trim_matches(|c: char| c <= ' ');
let name = if name.is_empty() { new_world } else { name };
available_name(name, &taken)
.or_else(|| available_name("World", &taken))
.unwrap_or_else(|| "World".to_owned())
}
/// `FileUtil.sanitizeName`.
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| {
if ILLEGAL_FILE_CHARACTERS.contains(&c) || matches!(c, '.' | '/' | '"') {
'_'
} else {
c
}
})
.collect()
}
/// `FileUtil.COPY_COUNTER_PATTERN`, `(?<name>.*) \((?<count>\d*)\)`, matched whole: the name and
/// the digits. `.` matches no line terminator, `\d` only ASCII digits.
fn copy_counter(name: &str) -> Option<(&str, &str)> {
let inner = name.strip_suffix(')')?;
let digits = inner.len() - inner.trim_end_matches(|c: char| c.is_ascii_digit()).len();
let (rest, digits) = inner.split_at(inner.len() - digits);
let base = rest.strip_suffix(" (")?;
let line_end = |c: char| matches!(c, '\n' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}');
(!base.contains(line_end)).then_some((base, digits))
}
fn java_len(s: &str) -> usize {
s.encode_utf16().count()
}
/// `String.substring(0, n)` in UTF-16 units (a pair cut in two becomes U+FFFD).
fn java_prefix(s: &str, n: usize) -> String {
String::from_utf16_lossy(&s.encode_utf16().take(n).collect::<Vec<_>>())
}
/// `FileUtil.findAvailableName(folder, name, "")`: None where Java's `Integer.parseInt` of the
/// count throws.
fn available_name(name: &str, taken: &impl Fn(&str) -> bool) -> Option<String> {
let mut name = sanitize_name(name);
if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(&name)) {
name = format!("_{name}_");
}
let mut count = 0i32;
if let Some((base, digits)) = copy_counter(&name) {
count = digits.parse().ok()?;
name = base.to_owned();
}
if java_len(&name) > MAX_FILE_NAME {
name = java_prefix(&name, MAX_FILE_NAME);
}
loop {
let mut candidate = name.clone();
if count != 0 {
let counter = format!(" ({count})");
let room = MAX_FILE_NAME - counter.len();
if java_len(&candidate) > room {
candidate = java_prefix(&candidate, room);
}
candidate.push_str(&counter);
}
if !taken(&candidate) {
return Some(candidate);
}
count = count.wrapping_add(1);
}
}
#[cfg(test)]
mod tests {
use super::result_folder;
fn folder(name: &str, taken: &[&str]) -> String {
result_folder(name, "New World", |f| taken.contains(&f))
}
#[test]
fn a_new_worlds_folder_is_named_as_vanilla_names_it() {
// A blank name takes the translated "New World", trimmed as String.trim does.
assert_eq!(folder("", &[]), "New World");
assert_eq!(folder(" \t ", &[]), "New World");
assert_eq!(folder(" Base ", &[]), "Base");
// sanitizeName: only ILLEGAL_FILE_CHARACTERS and . / " become _.
assert_eq!(folder("My.World/Test", &[]), "My_World_Test");
assert_eq!(folder("Bob's world! #1", &[]), "Bob's world! #1");
assert_eq!(folder("a:b*c?d", &[]), "a_b_c_d");
// A name Windows reserves is wrapped in underscores (isPathPartPortable).
assert_eq!(folder("con", &[]), "_con_");
assert_eq!(folder("LPT1", &[]), "_LPT1_");
// A name already taken counts up from 1; a name that ends in a count goes on from it.
assert_eq!(folder("Hello", &["Hello"]), "Hello (1)");
assert_eq!(folder("Hello", &["Hello", "Hello (1)"]), "Hello (2)");
assert_eq!(folder("World (3)", &[]), "World (3)");
assert_eq!(folder("World (3)", &["World (3)"]), "World (4)");
// An empty count can't be parsed: findResultFolder falls back to "World".
assert_eq!(folder("Mine ()", &[]), "World");
// At most 255 characters, the count included.
let long = "a".repeat(300);
assert_eq!(folder(&long, &[]), "a".repeat(255));
let first = "a".repeat(255);
assert_eq!(
folder(&long, &[first.as_str()]),
format!("{} (1)", "a".repeat(251))
);
}
}
+36 -20
View File
@@ -119,7 +119,10 @@ pub fn join(config: &GuestConfig) -> Result<Tunnel, JoinError> {
Parsed::Long(invite) => {
if config.path != PathMode::RelayOnly
&& !invite.endpoints.is_empty()
&& let Some(t) = join_direct(&invite)?
&& let Some(t) = join_direct(
&invite,
invite.relay.is_some() && config.path != PathMode::DirectOnly,
)?
{
return Ok(t);
}
@@ -133,9 +136,12 @@ pub fn join(config: &GuestConfig) -> Result<Tunnel, JoinError> {
}
}
/// Dial every endpoint of the code; the first that answers as a Horizon
/// host gets the handshake, the others are dropped before it.
fn join_direct(invite: &Invite) -> Result<Option<Tunnel>, JoinError> {
/// Dial every endpoint of the code; each that answers as a Horizon host gets the handshake in
/// turn until one takes the code. Another host can answer first (a housemate's game at the same
/// private address on the default port), so a failed handshake moves on rather than ending the
/// join. None when no endpoint took it and `fallback` (the relay) is still to be tried; else the
/// last failure.
fn join_direct(invite: &Invite, fallback: bool) -> Result<Option<Tunnel>, JoinError> {
let (tx, rx) = channel();
let deadline = Instant::now() + Duration::from_secs(5);
let mut session = [0u8; 8];
@@ -154,23 +160,33 @@ fn join_direct(invite: &Invite) -> Result<Option<Tunnel>, JoinError> {
});
}
drop(tx);
let left = deadline.saturating_duration_since(Instant::now());
let Ok(mut stream) = rx.recv_timeout(left) else {
return Ok(None);
};
let key = crypto::exchange(&mut stream, &invite.secret_text(), "direct", session)
.map_err(|e| JoinError::Failed(e.to_string()))?;
let secure = session::secure(stream, &key, Role::Guest).map_err(|e| {
if crypto::is_wrong_code(&e) {
JoinError::WrongCode
} else {
JoinError::Failed(e.to_string())
let mut failure = None;
while let Ok(mut stream) = rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
let key = match crypto::exchange(&mut stream, &invite.secret_text(), "direct", session) {
Ok(key) => key,
Err(e) => {
failure.get_or_insert(JoinError::Failed(e.to_string()));
continue;
}
};
match session::secure(stream, &key, Role::Guest) {
Ok(secure) => {
return Ok(Some(Tunnel {
secure,
direct: true,
}));
}
// A wrong code says more than any other failure: it is the one kept.
Err(e) if crypto::is_wrong_code(&e) => failure = Some(JoinError::WrongCode),
Err(e) => {
failure.get_or_insert(JoinError::Failed(e.to_string()));
}
}
})?;
Ok(Some(Tunnel {
secure,
direct: true,
}))
}
match failure {
Some(failure) if !fallback => Err(failure),
_ => Ok(None),
}
}
/// Through a relay with its short code (its own direct race, then the
+3 -1
View File
@@ -1,6 +1,7 @@
//! Blocks and items as the client knows them, below gameplay: block facts
//! (`blocks`), item stacks and definitions (`items`), their data components
//! (`item_data`), enchantments, cooldowns, bundles and item stacks in NBT.
//! (`item_data`), mining speed (`mining`), enchantments, cooldowns, bundles and
//! item stacks in NBT.
//! horizon-client re-exports each module under its old path
//! (`horizon_client::items`, ...).
#![forbid(unsafe_code)]
@@ -16,6 +17,7 @@ pub mod flowers;
pub mod item_data;
pub mod item_nbt;
pub mod items;
pub mod mining;
pub mod random_sequences;
pub mod recipe_filters;
pub mod use_animation;
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more