Compare commits

...
Author SHA1 Message Date
Ludovic Marie a9dcea59e7 Merge pull request #1901 from ludovic111/feat/server-mods-commands
🤖 Agent: the parity stop at c5534f0d7 was friends_integration, which main broke through #2501 and #2598 fixes, plus remote_fireworks and server_player_sculk (load on the old runner; both pass on main on the Mac mini). None of the three is from this PR. Merged main (fde2dfffe, a clean merge); pr_ready --quick passed: 1357 tests, clippy, launcher.
2026-10-27 03:56:44 +01:00
Ludovic Marie 4ba8105ecc Merge pull request #2104 from rdbtCVS/fix/1482-evoker-fangs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the server sends entity event 4 once its warmup ends (`EvokerFangs.tick`), `handleEntityEvent(4)` starts the client's attack and plays the sound locally, and the client only counts `lifeTicks` down for the animation, while the server deals the damage and discards. Moving the host's sound to `play_unshared` matches that, since each client plays its own. It conflicted with #2094 (both hook entity events) once that merged, so I merged main into it, keeping both calls. `lan_e2e.py --check fangs` passes in both modes. It conflicted again with main in `tools/lan_e2e.py` (new checks on both sides), so I merged main in once more, keeping every check; clippy is clean. Fixes #1482. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-27 03:56:41 +01:00
Ludovic Marie 226b42a046 Merge pull request #2449 from RoyalPixelsDE/fix/end-portal-without-players
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `EnderDragonFight.tick` scans the state and places the exit portal only when `dragonEvent.getPlayers()` is not empty, so a force-loaded End with nobody in it gets no portal, as here; the new `forced_end` harness compares both servers. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:13 +01:00
Ludovic Marie 12381ec668 Merge pull request #2439 from RoyalPixelsDE/fix/generated-block-entities
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WorldGenRegion.setBlock` gives a block with a block entity a `DUMMY` entry that becomes `newBlockEntity`'s default, so the deep dark's sensors, catalysts and shriekers and potent sulfur get theirs, while placed data is kept. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:10 +01:00
Ludovic Marie 8376200697 Merge pull request #2438 from RoyalPixelsDE/fix/end-crystals-at-generation
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SpikeFeature` places the end crystals during decoration, so they are saved with the chunk that generates them, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:07 +01:00
Ludovic Marie 2dd5792ae3 Merge pull request #2436 from RoyalPixelsDE/fix/brushable-fall
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `FallingBlockEntity` with a `BrushableBlock` breaks it where it lands (`onBrokenAfterFall`'s sound and level event). Left for later: the game event uses the block position, not the bounding-box centre. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:03 +01:00
Ludovic Marie 94d063684c Merge pull request #2434 from RoyalPixelsDE/fix/forced-decoration-order
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ForceLoadCommand` adds its FORCED tickets x-outer, z-inner, all at once, so the decoration order follows from that; the new `forced_order` reproduces it with the same ticket simulation as `vanilla_order`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:54:00 +01:00
Ludovic Marie 9d8ecc533d Merge pull request #2450 from RoyalPixelsDE/fix/structure-starts-split
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: a chunk's `STRUCTURE_STARTS` are its `getAllStarts`, whether or not a piece reaches it; `starts_at` now gives those, as vanilla saves them. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:51:42 +01:00
Ludovic Marie e2428e8735 Merge pull request #2437 from RoyalPixelsDE/fix/structure-mob-finalize
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `StructureTemplate.placeEntities` and the pieces' markers call `finalizeSpawn(level, difficulty, STRUCTURE, null)` inside the `WorldGenRegion` with its random, and the save keys now follow `addAdditionalSaveData`. Your rebased head is the reviewed change plus the comment cleanup, on current main. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @FusselTV!
2026-10-27 03:49:24 +01:00
Ludovic Marie bad8c9eba4 Merge pull request #2474 from kacpherek/fix/unavailable-options
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: these three options have no system in Horizon yet, so they are drawn inactive with the same notice the other unavailable options use, and clicks do nothing. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:41 +01:00
Ludovic Marie 70cba407b5 Merge pull request #2472 from kacpherek/fix/music-frequency
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `MusicManager.MusicFrequency` and the option's listener (`setMinutesBetweenSongs`). I pushed one fix: in `SoundOptionsScreen` Music Frequency starts its own row (beside Music Toast), not beside Show Subtitles. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:38 +01:00
Ludovic Marie 291239c90c Merge pull request #2470 from kacpherek/fix/execute-eye-height-positioned-as
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `EntityAnchorArgument` and `execute positioned as` (it keeps the anchor). I pushed one fix: a small armor stand's eyes are at 0.9875 (`BABY_DIMENSIONS.withEyeHeight`), not half the type's, and the types' eye heights are floats widened as `getEyeY` does. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:36 +01:00
Ludovic Marie dfd0aa0266 Merge pull request #2469 from kacpherek/fix/high-contrast-pack
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the High Contrast option adds or removes the `high_contrast` pack (`Options.updateResourcePacks`), and your follow-up keeps the pack screen's selection in step. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:32 +01:00
Ludovic Marie f6e156a4ef Merge pull request #2468 from kacpherek/fix/left-handed-swing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `HumanoidModel.setupAttackAnimation` and `AnimationUtils.swingWeaponDown` use the main arm of a left-handed mob, and `PiglinModel`'s crossbow and admiring poses mirror with it. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:48:29 +01:00
Ludovic Marie e91678b994 Merge pull request #2589 from ludovic111/fix/remote-fireworks-slow-screens
🤖 Agent: remote_fireworks passes 3 of 3 on playhorizon (frames drawn on the CPU) with the earlier shots and the newly-red count.
2026-10-27 03:26:40 +01:00
Ludovic Marie 9e359f230d Merge pull request #2599 from ludovic111/feat/launcher-screenshots
🤖 Agent: launcher-features-1 — item 6 (screenshots gallery); base retargeted to main after #2597 merged, merges cleanly, CI green.
2026-10-27 03:17:15 +01:00
Ludovic Marie 783577d2de Merge pull request #2597 from ludovic111/feat/launcher-play-time
🤖 Agent: launcher-features-1 — item 5 (play time per instance); base retargeted to main after #2596 merged, merges cleanly, CI green.
2026-10-27 02:55:07 +01:00
Ludovic Marie 8458872d07 Merge pull request #2596 from ludovic111/feat/launcher-quick-play
🤖 Agent: play into a world / join a server + servers.dat list (item 4 of launcher-features-1), on main after #2593: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally (servers.dat round trip keeps every field and hidden entries); screenshots in the PR.
2026-10-27 02:51:55 +01:00
Ludovic Marie cd520ce61d Merge pull request #2593 from ludovic111/feat/launcher-worlds
🤖 Agent: worlds in the launcher (item 3 of launcher-features-1), on main after #2591: verify and launcher CI passed on this head, merges cleanly with main; launcher fmt, clippy -D warnings and lib tests pass locally (vanilla-style backup layout, copy naming); screenshots in the PR.
2026-10-27 02:48:44 +01:00
Ludovic Marie 55cc84718a Merge remote-tracking branch 'origin/main' into feat/server-mods-commands 2026-10-27 02:24:14 +01:00
Ludovic MarieandClaude Opus 5.5 0d5ed8122e Launcher: screenshots gallery per instance
Home shows the selected instance's newest screenshots (what F2 saves in its
screenshots/ folder), all of them on request, and opens one over the window
with Newer/Older (and the arrow keys), Copy (the picture to the clipboard),
Open, Show in folder and Delete (to the trash, after a confirmation).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:09:39 +01:00
Ludovic Marie 4fe8f71530 Merge remote-tracking branch 'origin/main' into feat/server-mods-commands 2026-10-27 02:03:13 +01:00
Ludovic MarieandClaude Opus 5.5 ea0cc65aea Launcher: play time per instance
Every run of the game started from the window or `horizon-launcher play`
adds its length to its instance's play time in instances.json (by id, so a
renamed instance keeps it), with when it ended. Home's Instance row shows
"3 h 06 min in total · last played today"; `instance list` shows it too,
and its JSON gives played_secs and last_played_ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:02:15 +01:00
Ludovic MarieandClaude Opus 5.5 4b761abcb2 Launcher: play straight into a world or server, and the Multiplayer list
Each world on Home gets Play (the game starts with --world <folder>), and
Home lists the selected instance's servers from the game's own servers.dat:
Join starts the game with --server <address>, Add server and Remove change
the file. server_list reads and writes it as vanilla's ServerList does
(uncompressed NBT, a new file then servers.dat_old) and writes every entry
back with all its fields, the hidden Direct Connection ones too.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:37:31 +01:00
Ludovic MarieandClaude Opus 5.5 bedba4a3d9 lan_e2e fireworks: count newly red pixels, take the later shots 4 ticks after the burst
On playhorizon (frames drawn on the CPU) a screenshot takes ~0.6 s: with a
16-tick wait the later shots came after the sparks' lifetime, and the setup's
own red chat lines, gone by then, made the host's count negative.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:12:22 +01:00
Ludovic Marie c5534f0d7b Merge remote-tracking branch 'origin/main' into feat/server-mods-commands
# Conflicts:
#	crates/horizon-mods/src/lib.rs
2026-10-26 22:14:09 +01:00
Ludovic MarieandClaude Opus 5.5 1fe0f5fcb9 Sound options: Music Frequency starts its own row, as in SoundOptionsScreen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:39 +01:00
Ludovic MarieandClaude Opus 5.5 c4d0cb1ec0 A small armor stand's eyes are at 0.9875, and eye heights are floats
ArmorStand.BABY_DIMENSIONS keeps its own eye height rather than half the
type's, and EntityDimensions.eyeHeight is a float widened by getEyeY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:55:51 +01:00
Ludovic MarieandClaude Opus 5.5 84f8205bdd Merge main into fix/1482-evoker-fangs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:30:40 +01:00
Kacpherek 56bf45cee5 Keep the pack screen selection in step with High Contrast 2026-10-26 21:23:03 +01:00
Kacpherek 0e96fb6d2a Disable unavailable options in the Graphics Advanced screen 2026-10-26 21:21:06 +01:00
Kacpherek 34dabe4afd Grey out Chunk Fade-in, Improved Transparency and Chunk Builder 2026-10-26 20:41:49 +01:00
Kacpherek ad6f2320b0 Wire the Music Frequency option to the music manager 2026-10-26 20:40:06 +01:00
Kacpherek 4201fd23c0 Use real eye heights in /execute and keep the anchor on positioned as 2026-10-26 20:39:05 +01:00
Kacpherek 46b71954e1 Make the High Contrast option turn its resource pack on and off 2026-10-26 20:38:38 +01:00
Kacpherek b92a53e47c Swing and pose the main arm of a left-handed mob 2026-10-26 20:37:52 +01:00
FusselTVandClaude Opus 5.5 61df807b33 Structures: a chunk saves its starts also when no piece reaches it
A chunk's STRUCTURE_STARTS are the starts made in it
(StructureManager.getAllStarts), whether or not one of their pieces
reaches it. own_starts filtered the chunk's references, which only hold
starts with a piece in the chunk, so a start whose pieces all lie next
door (seed 8675309's giant ruined portal in chunk -26, 23) was not saved.
The proto chunk now keeps the starts made at its position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:32:44 +01:00
FusselTVandClaude Opus 5.5 ec81bea5bf Worldgen: blocks with a block entity get vanilla's default one
WorldGenRegion.setBlock gives every block that has a block entity a new
one, so vanilla saves the sculk sensors, catalysts and shriekers of the
deep dark and potent sulfur with their block entities. Horizon only
saved block entities that a structure or feature placed data for, and
these kinds have none, so a Horizon-made world had none of them (3,035
sculk sensors, 753 catalysts, 329 shriekers and 21 potent sulfur over
three 1,024-block seeds). A finished column now adds vanilla's defaults
(block_entity_nbt::canonical) for every such block: as the modelled
block entity where Horizon has one (sculk), as raw NBT otherwise.
Potent sulfur gets its block entity type.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
Ludovic MarieandClaude Opus 5.5 7ac90ff0b8 Merge main into fix/1482-evoker-fangs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:28:40 +01:00
rdbtCVSandOpenCode GPT-6.1 Sol 5eef2bea0c Clean up evoker summons after fang integration check
Remove the test arena's surviving vexes so later checks receive no stray attacks.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 14:45:59 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol 02897ac731 Merge main into fix/1482-evoker-fangs
Preserve both fang and sonic-wave integration checks.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 13:54:53 +00:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol f98528bbfc Fix evoker fangs rendering on server clients
Track fang entities and deliver their start event to the existing client renderer. Exercise warmup, animation, authoritative positions and removal in the LAN harness.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:35:09 +01:00
78 changed files with 4866 additions and 241 deletions

No files matched your search

+1 -1
View File
@@ -200,7 +200,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
return Err(format!("it didn't start: {reason}"));
}
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(|c| c.name.clone()).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
@@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> {
pose,
num("maxCrossbowChageDuration"),
num("ticksUsingItem"),
false,
);
}
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
+148 -6
View File
@@ -180,7 +180,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("Silent", m.silent),
("NoGravity", m.no_gravity),
("Glowing", m.glowing),
("Invulnerable", m.invulnerable_flag),
] {
if on {
map.insert(key.into(), Tag::Byte(1));
@@ -188,6 +187,26 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
);
map.insert(
"LeftHanded".into(),
Tag::Byte(i8::from(m.extra.left_handed)),
);
// Keys of older formats that structure templates still carry.
for key in [
"HandItems",
"ArmorItems",
"HandDropChances",
"ArmorDropChances",
"Dimension",
"HurtByTimestamp",
"Leashed",
] {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
if m.ticks_frozen > 0 {
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
} else {
@@ -197,7 +216,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("LeftHanded", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
(
"Brain",
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
@@ -209,7 +228,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
// Only breedable animals have an age; a loaded creature that was saved without
// one (a frog) stays without.
let aged = raw.is_none_or(|r| r.get("Age").is_some());
if m.kind.category == "creature" && aged {
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
@@ -260,6 +279,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
if id == "minecraft:piglin_brute" {
map.remove(&"CannotHunt".into());
}
map.insert(
"IsImmuneToZombification".into(),
Tag::Byte(i8::from(m.extra.flag2)),
@@ -498,8 +521,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
)])
});
}
"minecraft:zombie" => {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
for (key, value) in [
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
@@ -532,6 +554,21 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
Tag::Byte(i8::from(m.extra.can_break_doors)),
);
}
if matches!(
id,
"minecraft:zombie"
| "minecraft:husk"
| "minecraft:drowned"
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:piglin"
| "minecraft:zoglin"
) {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
}
if let Some(a) = m.extra.attributes.as_deref() {
put_attributes(map, m, a);
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -614,6 +651,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
tag
}
/// `AttributeMap.pack` for the attributes with a base or permanent modifier
/// finalizeSpawn set, over the saved list.
fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) {
use crate::mobs::finalize::{self, Attr, Op};
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
for attr in Attr::ALL {
let modifiers: Vec<Tag> = a
.modifiers
.iter()
.filter(|x| x.attr == attr)
.map(|x| {
let op = match x.op {
Op::AddValue => "add_value",
Op::AddMultipliedBase => "add_multiplied_base",
Op::AddMultipliedTotal => "add_multiplied_total",
};
Tag::compound([
("id", string(x.id)),
("amount", Tag::Double(x.amount.to_bits())),
("operation", string(op)),
])
})
.collect();
if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) {
continue;
}
let id = format!("minecraft:{}", attr.name());
let mut entry = vec![
("id", string(&id)),
("base", Tag::Double(finalize::base_value(m, attr).to_bits())),
];
if !modifiers.is_empty() {
entry.push(("modifiers", Tag::List(modifiers)));
}
let entry = Tag::compound(entry);
match list
.iter_mut()
.find(|t| text(t.get("id")).as_deref() == Some(id.as_str()))
{
Some(t) => *t = entry,
None => list.push(entry),
}
}
map.insert("attributes".into(), Tag::List(list));
}
/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones.
pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
if m.extra.memories.is_empty() {
return;
}
let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for &(name, value, ttl) in &m.extra.memories {
let value = match name {
"hunted_recently" => Tag::Byte(value as i8),
"home" => match m.extra.home {
Some(p) => Tag::compound([
("dimension", string(dimension)),
("pos", Tag::IntArray(p.to_vec())),
]),
None => continue,
},
_ => Tag::Int(value as i32),
};
let mut memory = vec![("value", value)];
if ttl != i64::MAX {
memory.push(("ttl", Tag::Long(ttl)));
}
memories.insert(
format!("minecraft:{name}").as_str().into(),
Tag::compound(memory),
);
}
map.insert(
"Brain".into(),
Tag::compound([("memories", Tag::Compound(memories))]),
);
}
/// `CustomName`: a text component saved as a string or a `{text: ..}` compound.
fn custom_name(tag: &Tag) -> Option<String> {
match tag.get("CustomName")? {
@@ -633,6 +753,22 @@ pub fn name_text(tag: &Tag) -> Text {
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
let Some(Tag::List(attrs)) = tag.get("attributes") else {
return None;
};
let range = attrs
.iter()
.find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?;
let Some(Tag::List(modifiers)) = range.get("modifiers") else {
return None;
};
let bonus = modifiers
.iter()
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
float(bonus.get("amount"))
}
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
@@ -658,6 +794,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
}
}
if let Some(bonus) = spawn_bonus(tag) {
use crate::mobs::finalize::{Attr, Op, add_modifier};
let id = "minecraft:random_spawn_bonus";
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
@@ -699,7 +840,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
if m.kind.category == "creature" {
m.extra.left_handed = flag(tag, "LeftHanded");
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
@@ -35,6 +35,18 @@ impl Damage {
..Self::default()
}
}
/// A block that starts to fall: anvils hurt, and a brushable block
/// breaks where it lands (BrushableBlock.tick: disableDrop).
pub fn falling(block: &str) -> Self {
if block.ends_with("anvil") {
Self::anvil()
} else {
Self {
cancel_drop: block.starts_with("minecraft:suspicious_"),
..Self::default()
}
}
}
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
Self {
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
@@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_brushable_blocks_break_where_they_land() {
assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop);
assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop);
assert!(!Damage::falling("minecraft:gravel").cancel_drop);
assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities);
}
#[test]
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
let damage = Damage::anvil();
+14 -6
View File
@@ -3047,11 +3047,9 @@ impl Game<'_> {
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
damage: if self.res.facts().get(state).name.ends_with("anvil") {
horizon_client::falling_block::Damage::anvil()
} else {
Default::default()
},
damage: horizon_client::falling_block::Damage::falling(
&self.res.facts().get(state).name,
),
saved: None,
});
}
@@ -4740,6 +4738,7 @@ impl Game<'_> {
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
self.share_spawn_conditions();
self.update_chunk_tickets();
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
// Player.tick's ItemCooldowns.tick for the window's player (a
@@ -8175,6 +8174,7 @@ impl Game<'_> {
);
self.session_lock = Some(session_lock);
self.end.set_locator(locator);
self.share_spawn_conditions();
self.score = Default::default();
self.fns = Default::default();
self.bosses = Default::default();
@@ -8313,6 +8313,7 @@ impl Game<'_> {
seed,
)?;
self.end.set_locator(locator);
self.share_spawn_conditions();
Ok((streaming, save))
}
/// Change dimension through a portal (Entity.changeDimension with
@@ -12707,7 +12708,14 @@ impl<'a> Game<'a> {
level,
session_lock: None,
audio,
music: Default::default(),
music: {
// The manager starts with the saved option (MusicManager's constructor).
let mut music = horizon_client::music::MusicManager::default();
music.frequency = horizon_client::music::MusicFrequency::parse(
&settings.file.name("musicFrequency"),
);
music
},
ambience: Default::default(),
sound_paused: false,
cart_sounds: HashSet::new(),
@@ -145,9 +145,42 @@ impl Game<'_> {
.iter()
.find(|m| m.id == id)
.map_or(0.0, |m| m.eye_y() - m.position.y),
_ => 0.0,
// ArmorStand.getDefaultDimensions: a marker's are empty, a small
// stand's are BABY_DIMENSIONS, whose eye height is 0.9875F.
Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) {
Some(a) if a.marker => 0.0,
Some(a) if a.small => f64::from(0.9875f32),
_ => self.type_eye_height(&self.target_type(t)),
},
// Entity.getEyeHeight: the type's dimensions.
_ => self.type_eye_height(&self.target_type(t)),
}
}
/// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`,
/// read once: a float, widened as `Entity.getEyeY` does.
fn type_eye_height(&self, ty: &str) -> f64 {
static EYE_HEIGHTS: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
std::sync::OnceLock::new();
EYE_HEIGHTS
.get_or_init(|| {
std::fs::read(self.options.resources.join("entity-types.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
v.as_object().map(|o| {
o.iter()
.filter_map(|(k, t)| {
Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32)))
})
.collect()
})
})
.unwrap_or_default()
})
.get(ty)
.copied()
.unwrap_or(0.0)
}
/// EntityAnchorArgument.Anchor.apply.
pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] {
match s.entity {
@@ -467,9 +500,10 @@ impl Game<'_> {
"positioned" if c.has("as") => targets(self)?
.into_iter()
.filter_map(|t| {
// withPosition keeps the anchor (only `positioned <pos>`
// resets it to the feet).
Some(Source {
pos: self.target_position(t)?,
eyes: false,
..s
})
})
@@ -244,7 +244,11 @@ impl Game<'_> {
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
while self.pregen_runs_here() {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
while self.pregen_runs_here()
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
{
let now = Instant::now();
if now >= until {
break;
@@ -120,6 +120,9 @@ impl Game<'_> {
tracker.track(&holder(chunk_text(x, z)), i32::from(changed));
}
}
if add {
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
}
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
let verb = if add { "added" } else { "removed" };
let (message, total) = tracker.message(
@@ -1083,6 +1083,9 @@ impl Game<'_> {
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
let mut fangs_vertices = Vec::new();
self.fangs_geometry(glam::DVec3::ZERO, 0.5, &mut fangs_vertices);
status["fangs_vertices"] = json!(fangs_vertices.len());
status["maps"] = json!(self.maps_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
@@ -430,6 +430,12 @@ impl Game<'_> {
),
}
}
/// What the generator finalizes this level's structure mobs with.
pub(super) fn share_spawn_conditions(&self) {
if let Some(terrain) = self.end.locator() {
terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time);
}
}
/// `ServerLevel.getCurrentDifficultyAt`.
pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty {
use horizon_world::food::Difficulty;
+3 -1
View File
@@ -2598,7 +2598,9 @@ impl Game<'_> {
}
pub(super) fn fight_ready(&mut self) -> bool {
self.load_fight();
self.fight.loaded
// spawnExitPortal runs from scanState and the dragon's death, both
// only with players in the fight.
self.fight.loaded && self.fight.players
}
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
self.fight.exit_portal
@@ -64,6 +64,25 @@ impl Game<'_> {
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
} else if self
.res
.facts()
.get(block.state)
.name
.starts_with("minecraft:suspicious_")
{
// BrushableBlock.onBrokenAfterFall.
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.level_events.push(LevelEvent::LevelEvent {
id: 2001,
pos: at,
data: block.state.0 as i32,
});
self.level_events.push(LevelEvent::GameEvent {
pos: at,
event: "minecraft:block_destroy",
state: None,
});
}
return;
}
+37 -5
View File
@@ -7,12 +7,13 @@ use std::sync::Arc;
#[derive(Clone, Debug)]
pub(super) struct Fangs {
pos: [f64; 3],
yaw: f32,
pub id: u32,
pub pos: [f64; 3],
pub yaw: f32,
warmup: i32,
life: i32,
pub life: i32,
owner: u32,
started: bool,
pub started: bool,
}
#[derive(Default)]
pub(super) struct FangState {
@@ -21,6 +22,11 @@ pub(super) struct FangState {
}
impl Game<'_> {
pub(super) fn add_fangs(&mut self, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
self.next_entity += 1;
self.insert_fangs(self.next_entity, at, yaw, delay, owner);
}
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
if self.fangs.model.is_none() {
self.fangs.model = horizon_client::mobs::load_model(
&self.options.resources,
@@ -29,6 +35,7 @@ impl Game<'_> {
.map(Arc::new);
}
self.fangs.list.push(Fangs {
id,
pos: at,
yaw,
warmup: delay,
@@ -37,6 +44,30 @@ impl Game<'_> {
started: false,
});
}
/// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack.
pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) {
if event == 4
&& let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id)
{
f.started = true;
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
1.0,
);
}
}
/// `EvokerFangs.tick`: clients only advance animation, never damage or discard.
pub(super) fn tick_remote_fangs(&mut self) {
for f in &mut self.fangs.list {
if f.started {
f.life -= 1;
}
}
}
/// `EvokerFangs.tick`.
pub(super) fn tick_fangs(&mut self) {
let mut list = std::mem::take(&mut self.fangs.list);
@@ -50,7 +81,8 @@ impl Game<'_> {
}
if !f.started {
f.started = true;
self.audio.play(
self.lan_entity_event(f.id as i32, 4, f.pos);
self.audio.play_unshared(
"minecraft:entity.evoker_fangs.attack",
Some(f.pos),
1.0,
@@ -304,6 +304,9 @@ impl Game<'_> {
None => 0,
};
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
for f in &self.fangs.list {
out.push(state(f.id, "minecraft:evoker_fangs", f.pos, f.yaw, false));
}
// LightningBolt: each client draws and flashes its own.
for b in self.bolts.iter().filter(|b| b.id != 0) {
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
+3 -1
View File
@@ -192,6 +192,7 @@ impl Game<'_> {
let mut locator = self.end.locator();
swap(&mut locator, &mut p.locator);
self.end.set_locator(locator);
self.share_spawn_conditions();
swap(&mut self.save, &mut p.save);
swap(&mut self.block_ticks, &mut p.block_ticks);
swap(&mut self.tick_chunks, &mut p.tick_chunks);
@@ -399,7 +400,8 @@ impl Game<'_> {
}
*next += self.clock.tick_duration();
if *next > now {
// A `/pregen` task keeps the workers fed until the next tick.
// A `/pregen` task or force-loaded chunks keep the workers fed
// until the next tick.
self.pump_pregen(*next);
let now = Instant::now();
if *next > now {
+4 -1
View File
@@ -371,7 +371,10 @@ impl Game<'_> {
}
/// The chat's tree after the running mods changed.
fn sync_mod_commands(&mut self) {
let mut names = self.mods.mods.commands().peekable();
// In-frame, the window runs server mods too: their names, free text
// here (the server parses their arguments).
let server = self.mods.mods.server_commands().map(|c| c.name.as_str());
let mut names = self.mods.mods.commands().chain(server).peekable();
self.mods.tree = names
.peek()
.is_some()
+6 -1
View File
@@ -3,7 +3,7 @@
use super::Game;
use horizon_client::{
blocks::Fluid,
music::{BackgroundMusic, Music, Situation},
music::{BackgroundMusic, Music, MusicFrequency, Situation},
sound_engine::TickContext,
};
@@ -11,6 +11,11 @@ impl Game<'_> {
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
pub(super) fn tick_music(&mut self) {
let (music, volume) = self.situational_music();
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
if frequency != self.music.frequency {
self.music.set_frequency(frequency, music.as_ref());
}
// LevelLoadingScreen: no new track before the player is in the world.
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
self.music
+10 -2
View File
@@ -357,7 +357,8 @@ impl Game<'_> {
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
self.next_entity += 1;
let uuid = entity_nbt::uuid(&tag);
// Structure entities placed with finalizeEntities.
// Structure mobs that generation left to finish: saves from before
// they were finalized at placement, or a placement that could not.
let mut tag = tag;
let mut finalize = false;
if let Tag::Compound(c) = &mut tag {
@@ -449,7 +450,8 @@ impl Game<'_> {
}
}
/// An entity of a type Horizon does not simulate, kept as its compound.
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
fn load_other(&mut self, mut tag: Tag, kind: &str) -> Option<u32> {
strip_finalize(&mut tag);
let position = entity_nbt::position(&tag)?;
let rotation = match tag.get("Rotation") {
Some(Tag::List(l)) => {
@@ -1014,3 +1016,9 @@ impl Game<'_> {
self.save.save_entities(chunks)
}
}
/// Only a mob is finalized; another entity drops the request.
fn strip_finalize(tag: &mut Tag) {
if let Tag::Compound(c) = tag {
c.remove(&"horizon:finalize".into());
}
}
+1 -1
View File
@@ -235,7 +235,7 @@ impl Game<'_> {
}
out
}
fn difficulty_id(&self) -> i32 {
pub(super) fn difficulty_id(&self) -> i32 {
use horizon_world::food::Difficulty;
match self.world.health_rules.difficulty {
Difficulty::Peaceful => 0,
+1
View File
@@ -2111,6 +2111,7 @@ impl Game<'_> {
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
self.remote_firework_explosion(p.id);
}
self.remote_fangs_event(p.id as u32, p.event);
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
@@ -95,6 +95,7 @@ impl Game<'_> {
let (yaw, pitch) = rotation;
self.remove_remote_projectile(id);
match kind {
"minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0),
"minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt {
id,
position: at,
@@ -296,6 +297,7 @@ impl Game<'_> {
/// One tick of interpolation, as `tick_remote_objects`.
pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
self.tick_remote_fangs();
// PrimedTnt.tick runs on a client too: it falls, bounces and burns its
// fuse down (the swell and flash follow it) between the server's
// updates (every 10 ticks), and a client discards it when the fuse
@@ -458,6 +460,7 @@ impl Game<'_> {
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
self.fangs.list.retain(|f| f.id != id);
self.tnts.retain(|t| t.id != id);
self.falling.retain(|f| f.id != id);
self.creatures.projectiles.retain(|p| p.id != id);
@@ -473,6 +476,7 @@ impl Game<'_> {
/// Every one a server sent (a new connection, level or dimension).
pub(super) fn clear_remote_projectiles(&mut self) {
self.fangs.list.clear();
self.tnts.clear();
self.falling.clear();
self.creatures.projectiles.clear();
@@ -493,6 +497,12 @@ impl Game<'_> {
use serde_json::json;
let at = |p: Vec3| [p.x, p.y, p.z];
let mut out = Vec::new();
for f in &self.fangs.list {
out.push(
json!({"id": f.id, "kind": "minecraft:evoker_fangs", "position": f.pos,
"yaw": f.yaw, "started": f.started, "life": f.life}),
);
}
for t in &self.tnts {
out.push(json!({"id": t.id, "kind": "minecraft:tnt", "position": at(t.position), "fuse": t.fuse}));
}
+73 -1
View File
@@ -59,9 +59,12 @@ mod run {
lan::data::component_nbt,
text::{self, Text},
};
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer, WorldView};
use horizon_mods::{
ArgumentKind, BlockPos, GameMode, Output, ServerCommand, ServerPlayer, WorldView,
};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::{Value, json};
/// Blocks queued for levels that don't tick (another dimension, in-frame)
/// are kept up to this many; the oldest go first.
@@ -98,6 +101,60 @@ mod run {
}
}
/// Server mods' commands as a root of the commands report
/// (`lan::commands`): a literal per command, then its arguments in a
/// chain, each node runnable where only optional arguments follow.
fn command_tree<'a>(commands: impl Iterator<Item = &'a ServerCommand>) -> Value {
let mut root = serde_json::Map::new();
for c in commands {
let mut next: Option<(String, Value)> = None;
for (i, a) in c.arguments.iter().enumerate().rev() {
let executable = c.arguments.get(i + 1).is_none_or(|b| b.optional);
let (parser, properties) = match a.kind {
ArgumentKind::Word => ("brigadier:string", json!({"type": "word"})),
ArgumentKind::Text => ("brigadier:string", json!({"type": "greedy"})),
ArgumentKind::Integer((min, max)) => {
let mut p = serde_json::Map::new();
if let Some(min) = min {
p.insert("min".into(), min.into());
}
if let Some(max) = max {
p.insert("max".into(), max.into());
}
("brigadier:integer", Value::Object(p))
}
ArgumentKind::Number => ("brigadier:double", Value::Null),
ArgumentKind::Boolean => ("brigadier:bool", Value::Null),
ArgumentKind::Player => (
"minecraft:entity",
json!({"amount": "single", "type": "players"}),
),
ArgumentKind::BlockPos => ("minecraft:block_pos", Value::Null),
};
let mut node = json!({
"type": "argument",
"parser": parser,
"properties": properties,
"executable": executable,
});
if let Some((name, child)) = next.take() {
node["children"] = json!({ name: child });
}
next = Some((a.name.clone(), node));
}
let executable = c.arguments.first().is_none_or(|a| a.optional);
let mut literal = json!({"type": "literal", "executable": executable});
if let Some((name, child)) = next {
literal["children"] = json!({ name: child });
}
root.insert(c.name.clone(), literal);
}
if root.is_empty() {
return Value::Null;
}
json!({"type": "root", "children": root})
}
/// `minecraft:oak_log[axis=y]`.
fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String {
let Some(s) = registry.state(state) else {
@@ -191,6 +248,7 @@ mod run {
// A server has no frames to check its dev mods in.
self.reload_dev_mods();
}
self.send_server_mod_commands();
if !self.mods.server {
return;
}
@@ -257,6 +315,20 @@ mod run {
self.apply_mod_block_writes();
}
/// Players' command trees list the running server mods' commands
/// after vanilla's, whenever they change (or a hub opens).
fn send_server_mod_commands(&mut self) {
let key = self.mods.mods.generation();
let Some(host) = self.lan.as_mut() else {
return;
};
if host.hub.mod_commands_key() == Some(key) {
return;
}
let tree = command_tree(self.mods.mods.server_commands());
host.hub.set_mod_commands(key, tree);
}
/// Lends the levels' chunks to capability `world` for the calls up to
/// [`Game::return_world`], when a running server mod has it.
fn lend_world(&mut self) {
+2 -1
View File
@@ -118,8 +118,9 @@ impl Style {
(x, y, w): (f32, f32, f32),
label: &Text,
hover: bool,
active: bool,
) {
ui::button(batch, atlas, x, y, w, 20.0, label, true, hover);
ui::button(batch, atlas, x, y, w, 20.0, label, active, hover);
}
pub fn slider(
&self,
+32 -1
View File
@@ -468,6 +468,10 @@ fn max_workers() -> u32 {
}
impl Item {
/// False for a setting whose system is not in Horizon yet (`Opt::available`).
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
@@ -510,6 +514,9 @@ impl Item {
}
}
pub fn set_slider(self, s: &mut Settings, v: f32) {
if !self.available() {
return;
}
match self {
Item::Vanilla(o) => o.set_slider(s, v),
Item::Horizon(h) => h.set_slider(s, v),
@@ -517,6 +524,9 @@ impl Item {
self.touched(s);
}
pub fn cycle(self, s: &mut Settings, back: bool, framebuffer: [i32; 2]) {
if !self.available() {
return;
}
match self {
Item::Vanilla(o) => o.cycle(s, back, framebuffer),
Item::Horizon(h) => h.cycle(s, back),
@@ -689,6 +699,7 @@ impl GraphicsUi {
})
.collect();
let mut index = 0;
let mut tooltip = None;
for wd in &widgets {
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
@@ -700,6 +711,13 @@ impl GraphicsUi {
continue;
}
let label = item.label(s);
let live = item.available();
if hover && !live {
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
}
match item.slider(s) {
Some(value) => STYLE.slider(
batch,
@@ -709,7 +727,14 @@ impl GraphicsUi {
value,
hover || self.dragging == Some(item),
),
None => STYLE.button(batch, atlas, (wd.x, wd.y, wd.w), &label, hover),
None => STYLE.button(
batch,
atlas,
(wd.x, wd.y, wd.w),
&label,
hover && live,
live,
),
}
STYLE.description(
batch,
@@ -732,6 +757,7 @@ impl GraphicsUi {
(wd.x, wd.y, wd.w),
&Text::literal(label),
hover,
true,
);
}
}
@@ -751,6 +777,9 @@ impl GraphicsUi {
Level::Advanced => "Graphics: Advanced",
};
STYLE.title(batch, atlas, &Text::literal(title));
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
}
}
/// A press (or a drag with `drag`) at `(x, y)`.
pub fn click(
@@ -788,6 +817,8 @@ impl GraphicsUi {
}
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if !item.available() => Vec::new(),
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
+1
View File
@@ -66,6 +66,7 @@ pub fn block_entity_type(name: &str) -> Option<&'static str> {
"sculk_catalyst" => "minecraft:sculk_catalyst",
"sculk_shrieker" => "minecraft:sculk_shrieker",
"crafter" => "minecraft:crafter",
"potent_sulfur" => "minecraft:potent_sulfur",
"chiseled_bookshelf" => "minecraft:chiseled_bookshelf",
"trial_spawner" => "minecraft:trial_spawner",
"vault" => "minecraft:vault",
+53 -10
View File
@@ -86,6 +86,18 @@ impl CommandTree {
/// The ClientboundCommandsPacket for a player with `level` permissions.
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
self.packet_with(level, dedicated, builtin, &Value::Null)
}
/// [`CommandTree::packet`] with more commands after vanilla's: `extra`
/// is a root in the report's format (server mods' commands).
pub fn packet_with(
&self,
level: u8,
dedicated: bool,
builtin: &Builtin,
extra: &Value,
) -> Commands {
let mut nodes = vec![Node {
name: String::new(),
kind: 0,
@@ -99,16 +111,18 @@ impl CommandTree {
let suggestions: HashMap<&str, &'static str> = SUGGESTIONS.iter().copied().collect();
let mut paths: HashMap<Vec<String>, usize> = HashMap::new();
paths.insert(Vec::new(), 0);
fill(
&self.root,
0,
&mut Vec::new(),
level,
dedicated,
&suggestions,
&mut nodes,
&mut paths,
);
for root in [&self.root, extra] {
fill(
root,
0,
&mut Vec::new(),
level,
dedicated,
&suggestions,
&mut nodes,
&mut paths,
);
}
// enumerateNodes: breadth first from the root, each node's children
// (in registration order) then its redirect target.
let mut ids = vec![None; nodes.len()];
@@ -365,4 +379,33 @@ mod tests {
);
assert!(op.nodes[3].restricted);
}
#[test]
fn server_mods_commands_follow_vanillas() {
let report: Value = serde_json::from_str(
r#"{"type":"root","children":{"me":{"type":"literal","children":{"action":{"type":"argument","parser":"minecraft:message","executable":true}}}}}"#,
)
.unwrap();
let mods: Value = serde_json::from_str(
r#"{"type":"root","children":{"beacon":{"type":"literal","executable":true,"children":{
"height":{"type":"argument","parser":"brigadier:integer","properties":{"min":1,"max":64},"executable":true}}}}}"#,
)
.unwrap();
let tree = CommandTree { root: report };
let packet = tree.packet_with(0, true, &Builtin::default(), &mods);
let names: Vec<_> = packet.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, ["", "me", "beacon", "action", "height"]);
let beacon = &packet.nodes[2];
assert!(beacon.executable && !beacon.restricted);
assert_eq!(beacon.children, [4]);
let height = packet.nodes[4].parser.as_ref().unwrap();
assert_eq!(height.1, vec![3, 0, 0, 0, 1, 0, 0, 0, 64]);
// No mods: vanilla's tree as it is.
assert_eq!(
tree.packet_with(0, true, &Builtin::default(), &Value::Null)
.nodes
.len(),
3
);
}
}
+28 -1
View File
@@ -177,6 +177,9 @@ pub struct Hub {
entities: std::collections::HashMap<String, Entities>,
/// What one tick of entity tracking sends each guest, kept between ticks.
entity_out: Vec<Vec<Vec<u8>>>,
/// Server mods' commands, a root in the commands report's format, sent
/// after vanilla's; the key the game last set them for.
mod_commands: (serde_json::Value, Option<u64>),
}
fn string(s: &str) -> Tag {
@@ -341,6 +344,7 @@ impl Hub {
entity_types: Default::default(),
entities: Default::default(),
entity_out: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
teams: Vec::new(),
}
}
@@ -849,13 +853,36 @@ impl Hub {
}
}
/// The key [`Hub::set_mod_commands`] last had: the game compares it
/// with its server mods' each tick.
pub fn mod_commands_key(&self) -> Option<u64> {
self.mod_commands.1
}
/// Server mods' commands changed: every player gets their tree again.
pub fn set_mod_commands(&mut self, key: u64, commands: serde_json::Value) {
let resend = self.mod_commands.0 != commands;
self.mod_commands = (commands, Some(key));
if resend {
for i in 0..self.guests.len() {
if self.guests[i].placed {
self.send_commands(i);
}
}
}
}
/// Commands.sendCommands: the command tree for this player.
fn send_commands(&self, i: usize) {
let g = &self.guests[i];
let packet = match &self.sync.commands {
Some(tree) => {
let dedicated = self.config.dedicated && !self.config.integrated;
tree.packet(g.permission, dedicated, &self.sync.builtin)
tree.packet_with(
g.permission,
dedicated,
&self.sync.builtin,
&self.mod_commands.0,
)
}
None => play::Commands {
nodes: vec![play::CommandNode {
+27 -12
View File
@@ -248,18 +248,24 @@ pub enum ArmPose {
pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) {
humanoid(p, s, &Hum::new(ArmPose::Empty, ArmPose::Empty));
}
/// `AnimationUtils.swingWeaponDown` with the right arm as the main arm.
pub(super) fn swing_weapon_down(p: &mut Poses, swing: f32, age: f32) {
/// `AnimationUtils.swingWeaponDown`: the main arm swings down, the other
/// one hangs and bobs.
pub(super) fn swing_weapon_down(p: &mut Poses, swing: f32, age: f32, left_main: bool) {
let attack2 = (swing * PI).msin();
let attack = ((1.0 - (1.0 - swing) * (1.0 - swing)) * PI).msin();
let (main, off) = if left_main {
("left_arm", "right_arm")
} else {
("right_arm", "left_arm")
};
p.zrot("right_arm", 0.0);
p.zrot("left_arm", 0.0);
p.yrot("right_arm", PI / 20.0);
p.yrot("left_arm", -PI / 20.0);
p.xrot("right_arm", -1.884_955_8 + (age * 0.09).mcos() * 0.15);
p.xrot("left_arm", -0.0 + (age * 0.19).mcos() * 0.5);
p.get("right_arm")[3] += attack2 * 2.2 - attack * 0.4;
p.get("left_arm")[3] += attack2 * 1.2 - attack * 0.4;
p.xrot(main, -1.884_955_8 + (age * 0.09).mcos() * 0.15);
p.xrot(off, -0.0 + (age * 0.19).mcos() * 0.5);
p.get(main)[3] += attack2 * 2.2 - attack * 0.4;
p.get(off)[3] += attack2 * 1.2 - attack * 0.4;
bob(p, "right_arm", age, 1.0);
bob(p, "left_arm", age, -1.0);
}
@@ -286,6 +292,8 @@ pub struct Hum {
pub using_item: bool,
/// The arm the current swing moves (`Some(true)` for the right one).
pub swing_right_arm: Option<bool>,
/// `mainArm` is the left one (`Mob.isLeftHanded`): a mob's swing uses it.
pub left_main: bool,
}
impl Hum {
pub fn new(right: ArmPose, left: ArmPose) -> Self {
@@ -303,6 +311,7 @@ impl Hum {
swim: 0.0,
using_item: false,
swing_right_arm: None,
left_main: false,
}
}
}
@@ -315,7 +324,7 @@ impl ArmPose {
}
}
/// `AnimationUtils.animateCrossbowHold`.
fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
pub(super) fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
let (hold, shoot) = if in_right {
("right_arm", "left_arm")
} else {
@@ -327,7 +336,7 @@ fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) {
p.xrot(shoot, -1.5 + hx);
}
/// `AnimationUtils.animateCrossbowCharge`.
fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: bool) {
pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: bool) {
let (hold, pull) = if in_right {
("right_arm", "left_arm")
} else {
@@ -418,13 +427,17 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
pose_arm(p, h.right, true, h);
}
}
// setupAttackAnimation (WHACK swing with the right arm).
// setupAttackAnimation (WHACK swing with the arm of the swinging hand).
let hx = p.get("head")[3];
let swing = s.swing;
let swing_left = h.swing_right_arm.map_or(h.left_main, |right| !right);
if swing > 0.0 && h.weapon_swing {
swing_weapon_down(p, swing, s.age);
swing_weapon_down(p, swing, s.age, h.left_main);
} else if swing > 0.0 {
let body_y = (swing.sqrt() * PI * 2.0).msin() * 0.2;
let mut body_y = (swing.sqrt() * PI * 2.0).msin() * 0.2;
if swing_left {
body_y = -body_y;
}
p.yrot("body", body_y);
let age_scale = if s.baby { 0.5 } else { 1.0 };
{
@@ -444,7 +457,7 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
let eased = 1.0 - (1.0 - swing).powi(4);
let aa = (eased * PI).msin();
let bb = (swing * PI).msin() * -(hx - 0.7) * 0.75;
let r = p.get("right_arm");
let r = p.get(if swing_left { "left_arm" } else { "right_arm" });
r[3] -= aa * 1.2 + bb;
r[4] += body_y * 2.0;
r[5] += (swing * PI).msin() * -0.4;
@@ -652,6 +665,7 @@ pub fn setup(
} else {
Hum::new(main, ArmPose::Empty)
};
h.left_main = m.main_arm_left();
h.zombie = Some(m.ai.aggressive);
h.holding = m.extra.main_hand.is_some();
h.crouching = m.extra.crouching;
@@ -715,6 +729,7 @@ pub fn setup(
h.swim = m.extra.swim.amount(s.partial);
h.using_item = m.extra.using_item > 0;
h.swing_right_arm = m.extra.swing_right_arm;
h.left_main = m.main_arm_left();
humanoid(p, s, &h);
if m.ai.aggressive && !bow {
// SkeletonModel melee pose.
+8 -3
View File
@@ -681,7 +681,12 @@ pub fn anim_pose(m: &Mob, s: &State, p: &mut Poses, pose: ArmPose) {
p.xrot("right_arm", x + a * 1.2 - b * 0.4);
p.xrot("left_arm", x + a * 1.2 - b * 0.4);
} else {
// swingWeaponDown (right-handed).
// swingWeaponDown: the main arm swings down.
let (main, off) = if m.main_arm_left() {
("left_arm", "right_arm")
} else {
("right_arm", "left_arm")
};
let swing = s.swing;
let a2 = (swing * PI).msin();
let a = ((1.0 - (1.0 - swing) * (1.0 - swing)) * PI).msin();
@@ -690,11 +695,11 @@ pub fn anim_pose(m: &Mob, s: &State, p: &mut Poses, pose: ArmPose) {
p.yrot("right_arm", PI / 20.0);
p.yrot("left_arm", -PI / 20.0);
p.xrot(
"right_arm",
main,
-1.884_955_8 + (s.age * 0.09).mcos() * 0.15 + (a2 * 2.2 - a * 0.4),
);
p.xrot(
"left_arm",
off,
-0.0 + (s.age * 0.19).mcos() * 0.5 + (a2 * 1.2 - a * 0.4),
);
}
+31 -21
View File
@@ -431,7 +431,14 @@ pub fn piglin_anim(m: &Mob, s: &State, p: &mut Poses) {
} else {
PiglinPose::Default
};
piglin_pose(p, s, pose, 25.0, m.extra.using_item as f32);
piglin_pose(
p,
s,
pose,
25.0,
m.extra.using_item as f32,
m.main_arm_left(),
);
}
/// `PiglinArmPose`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -443,11 +450,19 @@ pub enum PiglinPose {
CrossbowCharge,
AdmiringItem,
}
/// `PiglinModel.setupAnim` for a piglin facing right-handed.
pub fn piglin_pose(p: &mut Poses, s: &State, pose: PiglinPose, max_charge: f32, ticks: f32) {
use super::anim::{ArmPose, Hum, humanoid};
/// `PiglinModel.setupAnim`; `left` when the piglin's main arm is the left one.
pub fn piglin_pose(
p: &mut Poses,
s: &State,
pose: PiglinPose,
max_charge: f32,
ticks: f32,
left: bool,
) {
use super::anim::{ArmPose, Hum, crossbow_charge, crossbow_hold, humanoid};
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
h.weapon_swing = pose == PiglinPose::MeleeWeapon;
h.left_main = left;
h.charge = (max_charge, ticks);
humanoid(p, s, &h);
ears(p, s);
@@ -474,27 +489,22 @@ pub fn piglin_pose(p: &mut Poses, s: &State, pose: PiglinPose, max_charge: f32,
p.get("left_arm")[1] += (dance * 40.0).msin() * 0.5 + 0.5;
p.get("body")[1] += (dance * 40.0).msin() * 0.35;
}
PiglinPose::MeleeWeapon if s.swing == 0.0 => p.xrot("right_arm", -1.8),
PiglinPose::CrossbowHold => {
p.yrot("right_arm", -0.3 + hy);
p.yrot("left_arm", 0.6 + hy);
p.xrot("right_arm", -PI / 2.0 + hx + 0.1);
p.xrot("left_arm", -1.5 + hx);
}
PiglinPose::CrossbowCharge => {
p.yrot("right_arm", -0.8);
p.xrot("right_arm", -0.970_796_35);
p.xrot("left_arm", -0.970_796_35);
let alpha = ticks.clamp(0.0, max_charge) / max_charge;
p.yrot("left_arm", 0.4 + alpha * (0.85 - 0.4));
let x = -0.970_796_35 + alpha * (-PI / 2.0 - -0.970_796_35);
p.xrot("left_arm", x);
// holdWeaponHigh
PiglinPose::MeleeWeapon if s.swing == 0.0 => {
p.xrot(if left { "left_arm" } else { "right_arm" }, -1.8)
}
PiglinPose::CrossbowHold => crossbow_hold(p, hx, hy, !left),
PiglinPose::CrossbowCharge => crossbow_charge(p, max_charge, ticks, !left),
PiglinPose::AdmiringItem => {
p.xrot("head", 0.5);
p.yrot("head", 0.0);
p.yrot("left_arm", 0.5);
p.xrot("left_arm", -0.9);
if left {
p.yrot("right_arm", -0.5);
p.xrot("right_arm", -0.9);
} else {
p.yrot("left_arm", 0.5);
p.xrot("left_arm", -0.9);
}
}
_ => {}
}
+36
View File
@@ -67,6 +67,7 @@ pub enum Opt {
ReducedDebugInfo,
HighContrast,
ShowSubtitles,
MusicFrequency,
NotificationDisplayTime,
Narrator,
TextBackground,
@@ -172,6 +173,10 @@ impl Opt {
| Opt::MonochromeLogo
| Opt::RotateWithMinecarts
| Opt::HighContrastBlockOutlines
// Saved, but nothing in rendering reads them yet.
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
)
}
fn file_key(self) -> Option<&'static str> {
@@ -192,6 +197,7 @@ impl Opt {
Opt::NarratorHotkey => "narratorHotkey",
Opt::RotateWithMinecarts => "rotateWithMinecart",
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
Opt::MusicFrequency => "musicFrequency",
_ => return None,
})
}
@@ -248,6 +254,7 @@ impl Opt {
Opt::ReducedDebugInfo => "options.reducedDebugInfo",
Opt::HighContrast => "options.accessibility.high_contrast",
Opt::ShowSubtitles => "options.showSubtitles",
Opt::MusicFrequency => "options.music_frequency",
Opt::NotificationDisplayTime => "options.notifications.display_time",
Opt::Narrator => "options.narrator",
Opt::TextBackground => "options.accessibility.text_background",
@@ -578,6 +585,11 @@ impl Opt {
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
Opt::HighContrast => on_off(s.high_contrast),
Opt::ShowSubtitles => on_off(s.show_subtitles),
Opt::MusicFrequency => tr([
"options.music_frequency.default",
"options.music_frequency.frequent",
"options.music_frequency.constant",
][s.file.int("musicFrequency").clamp(0, 2) as usize]),
Opt::ForceUnicode => on_off(s.force_unicode),
Opt::JapaneseGlyphs => on_off(s.japanese_glyphs),
Opt::PadEnabled => on_off(s.controller.enabled),
@@ -747,6 +759,11 @@ impl Opt {
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
Opt::HighContrast => s.high_contrast = !s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
Opt::MusicFrequency => {
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
s.file
.set("musicFrequency", crate::options_file::V::Int(i32::from(n)));
}
Opt::ForceUnicode => s.force_unicode = !s.force_unicode,
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
@@ -1412,6 +1429,9 @@ impl OptionsUi {
let others: Vec<Opt> = (1..SOUND_SOURCES.len()).map(Opt::Volume).collect();
rows.extend(pairs(&others));
rows.extend(pairs(&[Opt::ShowSubtitles]));
// SoundOptionsScreen: Music Frequency starts the next row (with
// Music Toast, not in Horizon yet).
rows.extend(pairs(&[Opt::MusicFrequency]));
rows
}
Page::Controls => {
@@ -2533,6 +2553,21 @@ impl OptionsUi {
if opt == Opt::Preset {
apply_preset(s);
}
if opt == Opt::HighContrast {
// Options.highContrast: the built-in pack follows the option.
let on = s.resource_packs.iter().any(|id| id == "high_contrast");
if on != s.high_contrast {
s.resource_packs.retain(|id| id != "high_contrast");
if s.high_contrast {
s.resource_packs.push("high_contrast".into());
}
// PackSelectionScreen reads the repository each time it
// opens: the next one starts from this selection.
self.pack_selection = s.resource_packs.clone();
self.pack_screen = None;
events.push(Event::Packs(s.resource_packs.clone()));
}
}
events.push(Event::Changed);
}
Act::Extra(item) => {
@@ -2701,6 +2736,7 @@ impl OptionsUi {
{
let ids = p.commit();
s.resource_packs = ids.clone();
self.pack_selection = ids.clone();
events.push(Event::Packs(ids));
}
}
+8
View File
@@ -1417,6 +1417,14 @@ impl Streaming {
pub fn band(&self) -> Option<Band> {
self.band
}
/// Rank the chunks `/forceload add` over chunks `(x0, z0)..=(x1, z1)`
/// decorates in vanilla's order (`forced_order`), after every chunk
/// ranked before.
pub fn rank_forced(&mut self, x0: i32, z0: i32, x1: i32, z1: i32) {
for (x, z) in horizon_world::worldgen::decoration_order::forced_order(x0, z0, x1, z1) {
self.decoration_order.rank_or_append(x, z);
}
}
/// Generate, light and keep the chunks of `band` (none: stop), and let
/// go of what earlier bands held as it falls out of range. The chunks a
/// band reaches are ranked for decoration row by row, as they first come
+99 -2
View File
@@ -24,7 +24,9 @@ use horizon_world::{
};
use std::{path::Path, sync::Arc};
mod lod;
mod structure_mobs;
use lod::VanillaLodSampler;
use structure_mobs::StructureMobFinalizer;
pub struct VanillaTerrain {
generator: OverworldGenerator,
@@ -32,6 +34,7 @@ pub struct VanillaTerrain {
biome_map: Vec<u16>,
/// Loot tables for generated containers.
loot: Option<Arc<horizon_world::loot::LootData>>,
structure_mobs: Arc<StructureMobFinalizer>,
/// A worker for structure queries from the game thread.
query: std::sync::Mutex<Option<GeneratorWorker>>,
/// Block state id -> block index, the game's block tick type: worldgen
@@ -39,6 +42,8 @@ pub struct VanillaTerrain {
tick_blocks: Vec<u32>,
/// Block index -> default state id: `tick_blocks` the other way.
tick_states: Vec<u32>,
/// Block state id -> the block entity type of its block, if it has one.
block_entity_types: Vec<Option<&'static str>>,
seed: i64,
dimension: String,
/// When the last proto chunk was generated, in milliseconds since
@@ -51,6 +56,51 @@ enum VanillaProto {
Ready(Box<ProtoChunk>),
Failed(i32, i32),
}
/// WorldGenRegion.setBlock gives every block with a block entity a new
/// one: the blocks nothing placed data for (a sculk patch's sensors) get
/// vanilla's defaults.
fn add_default_block_entities(kinds: &[Option<&str>], out: &mut Chunk, raw: &mut Vec<Tag>) {
let kind = |v: u32| kinds.get(v as usize).copied().flatten();
let mut states = [0u32; 4096];
for (si, section) in out.sections.iter().enumerate() {
if !section.blocks.maybe_has(|v| kind(v).is_some()) {
continue;
}
section.unpack(&mut states);
for (i, &v) in states.iter().enumerate() {
let Some(id) = kind(v) else {
continue;
};
let i = i as i32;
let pos = [
out.x * 16 + (i & 15),
MIN_Y + si as i32 * 16 + (i >> 8),
out.z * 16 + ((i >> 4) & 15),
];
let at = |t: &Tag| {
["x", "y", "z"]
.iter()
.zip(pos)
.all(|(k, p)| t.get(k) == Some(&Tag::Int(p)))
};
if raw.iter().any(at) || out.block_entities.iter().any(|(p, _)| *p == pos.into()) {
continue;
}
let tag = Tag::compound([
("id", Tag::String(id.into())),
("x", Tag::Int(pos[0])),
("y", Tag::Int(pos[1])),
("z", Tag::Int(pos[2])),
]);
let tag = crate::block_entity_nbt::canonical(&tag).unwrap_or(tag);
match crate::save::decode_block_entity(&tag) {
Some(modelled) => out.block_entities.push(modelled),
None => raw.push(tag),
}
}
}
}
impl VanillaTerrain {
fn client_surface(&self, surface: SurfaceSample) -> crate::lod::Surface {
crate::lod::Surface {
@@ -119,6 +169,7 @@ impl VanillaTerrain {
}
let mut tick_blocks = Vec::with_capacity(registry.len());
let mut tick_states = Vec::new();
let mut block_entity_types = Vec::with_capacity(registry.len());
let mut last: Option<&str> = None;
let mut index = 0u32;
for id in 0..registry.len() {
@@ -133,20 +184,31 @@ impl VanillaTerrain {
}
last = Some(name);
tick_blocks.push(index);
block_entity_types.push(crate::lan::chunks::block_entity_type(name));
}
Ok(Self {
generator,
biome_map,
loot,
structure_mobs: Arc::new(StructureMobFinalizer::new(
dir.parent().unwrap_or(dir).to_owned(),
dimension,
)),
query: std::sync::Mutex::new(None),
tick_blocks,
tick_states,
block_entity_types,
seed,
dimension: dimension.to_owned(),
last_proto: std::sync::atomic::AtomicU64::new(0),
created: std::time::Instant::now(),
})
}
/// The difficulty (0 peaceful to 3 hard) and overworld clock structure
/// mobs are finalized with.
pub fn set_spawn_conditions(&self, difficulty: u8, day_time: i64) {
self.structure_mobs.set_conditions(difficulty, day_time);
}
/// A proto chunk's blocks, biomes, scheduled ticks and post-processing
/// marks as a column.
fn column(&self, chunk: &ProtoChunk) -> Chunk {
@@ -362,7 +424,9 @@ pub fn shared_data(
impl ChunkGenerator for VanillaTerrain {
type Worker = GeneratorWorker;
fn worker(&self) -> GeneratorWorker {
self.generator.worker()
let mut worker = self.generator.worker();
worker.set_structure_mobs(self.structure_mobs.clone());
worker
}
fn lod_sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
Box::new(VanillaLodSampler::new(self))
@@ -561,7 +625,7 @@ impl ChunkGenerator for VanillaTerrain {
// The block entities Horizon does not model (decorated pots with their
// sherds and loot table, brushable blocks) stay as placed, like a
// loaded chunk's.
let unmodelled: Vec<Tag> = chunk
let mut unmodelled: Vec<Tag> = chunk
.block_entities
.iter()
.filter(|(pos, _)| {
@@ -581,6 +645,7 @@ impl ChunkGenerator for VanillaTerrain {
Some(Tag::Compound(nbt))
})
.collect();
add_default_block_entities(&self.block_entity_types, &mut out, &mut unmodelled);
if !unmodelled.is_empty() || !rolled_loot.is_empty() {
let mut extras = out
.saved
@@ -604,3 +669,35 @@ impl ChunkGenerator for VanillaTerrain {
out
}
}
#[cfg(test)]
mod default_block_entity_tests {
use super::*;
#[test]
fn generated_blocks_get_their_block_entities() {
let mut out = Chunk::empty(-2, 3);
out.set(4, -40, 9, 7);
out.set(5, -40, 9, 6);
let mut kinds = vec![None; 8];
kinds[6] = Some("minecraft:potent_sulfur");
kinds[7] = Some("minecraft:sculk_sensor");
let mut raw = vec![];
add_default_block_entities(&kinds, &mut out, &mut raw);
assert!(matches!(
out.block_entities[..],
[((-28, -40, 57), crate::containers::BlockEntity::Sculk(_))]
));
assert_eq!(raw.len(), 1);
assert_eq!(
raw[0].get("id"),
Some(&Tag::String("minecraft:potent_sulfur".into()))
);
add_default_block_entities(&kinds, &mut out, &mut raw);
assert_eq!(
(out.block_entities.len(), raw.len()),
(1, 1),
"a placed block entity is kept, not doubled"
);
}
}
@@ -0,0 +1,446 @@
//! `Mob.finalizeSpawn(level, level.getCurrentDifficultyAt(pos), STRUCTURE, null)`
//! of the mobs structures place (StructureTemplate.placeEntities with
//! finalizeEntities, ocean ruin drowned, mansion illagers, witch huts,
//! monuments), run as decoration places them, with the WorldGenRegion's random.
use crate::{
enchant::Enchantments,
entity_nbt,
items::Items,
mobs::{
Mob, MobType, MobTypes,
finalize::{self, Ctx, FinalizeLevel, Reason, SpawnData, SpawnRegistries},
spawning::{LocalDifficulty, MOON_BRIGHTNESS},
},
};
use chrono::Datelike;
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource, XoroshiroRandom},
};
use horizon_world::{
nbt::{Compound, Tag},
worldgen::{generator::StructureMobs, structure::legacy::PieceRandom},
};
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::{
Arc, OnceLock,
atomic::{AtomicI64, AtomicU8, Ordering},
},
};
struct Data {
types: HashMap<String, Arc<MobType>>,
biome_tags: HashMap<String, HashSet<String>>,
registries: SpawnRegistries,
enchantments: Enchantments,
items: Items,
camel_husk: (f32, f32),
}
fn load(root: &std::path::Path) -> Option<Data> {
let types = MobTypes::load(root).ok()?.types;
let camel = types.get("minecraft:camel_husk")?;
let camel_husk = (camel.width as f32, camel.height as f32);
Some(Data {
types: types.into_iter().map(|(k, t)| (k, Arc::new(t))).collect(),
biome_tags: crate::items::load_tags(root, "worldgen/biome").unwrap_or_default(),
registries: SpawnRegistries::load(root),
enchantments: Enchantments::load(root),
items: Items::tags_only(root).ok()?,
camel_husk,
})
}
pub struct StructureMobFinalizer {
root: PathBuf,
dimension: String,
data: OnceLock<Option<Data>>,
/// The level's difficulty (0 peaceful to 3 hard) and overworld clock.
difficulty: AtomicU8,
day_time: AtomicI64,
}
impl StructureMobFinalizer {
pub fn new(root: PathBuf, dimension: &str) -> Self {
Self {
root,
dimension: dimension.to_owned(),
data: OnceLock::new(),
difficulty: AtomicU8::new(2),
day_time: AtomicI64::new(0),
}
}
pub fn set_conditions(&self, difficulty: u8, day_time: i64) {
self.difficulty.store(difficulty, Ordering::Relaxed);
self.day_time.store(day_time, Ordering::Relaxed);
}
}
struct Level<'a> {
data: &'a Data,
biome: &'a str,
structures: &'a [String],
difficulty: u8,
day_time: i64,
next_id: u32,
}
impl FinalizeLevel for Level<'_> {
fn biome(&self, _: [i32; 3]) -> String {
self.biome.to_owned()
}
fn biome_tag(&self, tag: &str, _: [i32; 3]) -> bool {
self.data
.biome_tags
.get(tag)
.is_some_and(|b| b.contains(self.biome))
}
fn structures_with_piece_at(&self, _: [i32; 3]) -> Vec<String> {
self.structures.to_vec()
}
fn moon_phase(&self, _: [i32; 3]) -> usize {
self.day_time.div_euclid(24000).rem_euclid(8) as usize
}
fn spawning_monsters(&self) -> bool {
self.difficulty != 0
}
// No block collision: only a husk's camel asks, and no structure places husks.
fn no_collision(&self, _: Aabb) -> bool {
true
}
// WorldGenRegion holds no entities.
fn free_chicken(&self, _: Aabb) -> Option<u32> {
None
}
fn ride_chicken(&mut self, _: u32, _: u32) {}
fn game_time(&self) -> i64 {
self.day_time
}
fn halloween(&self) -> bool {
let today = chrono::Local::now();
(today.month(), today.day()) == (10, 31)
}
fn create(&mut self, kind: &str, _: Reason) -> Option<Mob> {
if self.difficulty == 0 && !finalize::allowed_in_peaceful(kind) {
return None;
}
let t = self.data.types.get(kind)?.clone();
self.next_id += 1;
Some(Mob::new(self.next_id, t, Vec3::ZERO, 0.0))
}
}
fn run<R: RandomSource>(
level: &mut Level<'_>,
random: &mut R,
difficulty: LocalDifficulty,
data: &SpawnData<'_>,
m: &mut Mob,
) -> Vec<Mob> {
let mut cx = Ctx {
level,
random,
difficulty,
data,
created: Vec::new(),
};
cx.finalize(m, Reason::Structure, None);
cx.created
}
/// The saved compound of mob `i` with the mobs riding it under `Passengers`.
fn tree(mobs: &[(Mob, Option<Tag>)], i: usize, dimension: &str) -> Compound {
let (m, raw) = &mobs[i];
let Tag::Compound(mut c) = entity_nbt::mob_to_nbt(m, [0; 4], raw.as_ref()) else {
return Compound::new();
};
c.remove(&"UUID".into());
entity_nbt::put_brain(&mut c, m, dimension);
let riders: Vec<Tag> = (0..mobs.len())
.filter(|&j| mobs[j].0.extra.mount == Some(m.id))
.map(|j| Tag::Compound(tree(mobs, j, dimension)))
.collect();
if !riders.is_empty() {
c.insert("Passengers".into(), Tag::List(riders));
}
c
}
impl StructureMobs for StructureMobFinalizer {
fn finalize(
&self,
nbt: &mut Compound,
biome: &str,
structures: &[String],
random: &mut dyn PieceRandom,
) -> bool {
let Some(data) = self.data.get_or_init(|| {
let data = load(&self.root);
if data.is_none() {
log::error!("Horizon: no mob data, structure mobs are finalized when loaded");
}
data
}) else {
return false;
};
let mut raw = Tag::Compound(nbt.clone());
if let Tag::Compound(c) = &mut raw {
c.remove(&"horizon:finalize".into());
}
let Some(mut m) = entity_nbt::mob_from_nbt(&raw, &data.types, 1) else {
// Not a mob: vanilla finalizes only Mob instances.
return true;
};
let base = self.difficulty.load(Ordering::Relaxed);
let day_time = self.day_time.load(Ordering::Relaxed);
let mut level = Level {
data,
biome,
structures,
difficulty: base,
day_time,
next_id: 1,
};
let moon = MOON_BRIGHTNESS[level.moon_phase([0; 3])];
// WorldGenRegion.getCurrentDifficultyAt: no inhabited time.
let difficulty = LocalDifficulty::new(base, day_time, 0, moon);
let spawn = SpawnData {
registries: &data.registries,
enchantments: &data.enchantments,
items: &data.items,
camel_husk_size: data.camel_husk,
};
let any = random.as_any();
let created = if let Some(r) = any.downcast_mut::<LegacyRandom>() {
run(&mut level, r, difficulty, &spawn, &mut m)
} else if let Some(r) = any.downcast_mut::<XoroshiroRandom>() {
run(&mut level, r, difficulty, &spawn, &mut m)
} else {
log::error!("Horizon: unknown region random, structure mobs are finalized when loaded");
return false;
};
let mut mobs = vec![(m, Some(raw))];
mobs.extend(created.into_iter().map(|c| (c, None)));
let root = mobs
.iter()
.position(|(m, _)| m.extra.mount.is_none())
.unwrap_or(0);
*nbt = tree(&mobs, root, &self.dimension);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The bastion's crossbow piglin keeps its crossbow (STRUCTURE skips
/// createSpawnWeapon) and saves what Piglin.addAdditionalSaveData writes.
#[test]
fn bastion_crossbow_piglin_finalizes_as_structure() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES").map(PathBuf::from) else {
return;
};
let file = root.join("worldgen/structure/minecraft/bastion/mobs/crossbow_piglin.nbt");
let mut bytes = Vec::new();
std::io::Read::read_to_end(
&mut flate2::read::GzDecoder::new(std::fs::File::open(file).unwrap()),
&mut bytes,
)
.unwrap();
let limits = horizon_world::nbt::Limits::templates();
let template = horizon_world::nbt::read_named(&bytes, limits)
.unwrap()
.0
.tag;
let Some(Tag::List(entities)) = template.get("entities") else {
panic!("no entities");
};
let Some(Tag::Compound(mut nbt)) = entities[0].get("nbt").cloned() else {
panic!("no nbt");
};
nbt.remove(&"UUID".into());
let hook = StructureMobFinalizer::new(root, "minecraft:the_nether");
let mut random = LegacyRandom::new(12345);
hook.finalize(&mut nbt, "minecraft:nether_wastes", &[], &mut random);
let keys: Vec<String> = {
let mut k: Vec<String> = nbt.keys().map(|k| k.to_string_lossy()).collect();
k.sort();
k
};
assert_eq!(
keys,
[
"AbsorptionAmount",
"Air",
"Brain",
"CanPickUpLoot",
"CannotHunt",
"DeathTime",
"FallFlying",
"Fire",
"Health",
"HurtTime",
"Inventory",
"Invulnerable",
"IsBaby",
"IsImmuneToZombification",
"LeftHanded",
"Motion",
"OnGround",
"PersistenceRequired",
"PortalCooldown",
"Pos",
"Rotation",
"TimeInOverworld",
"attributes",
"current_impulse_context_reset_grace_time",
"equipment",
"fall_distance",
"id",
]
);
let tag = Tag::Compound(nbt);
let held = tag.get("equipment").and_then(|e| e.get("mainhand"));
assert!(
matches!(held.and_then(|h| h.get("id")), Some(Tag::String(s)) if s.to_string_lossy() == "minecraft:crossbow")
);
let hunted = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|m| m.get("minecraft:hunted_recently"));
assert!(matches!(
hunted.and_then(|h| h.get("ttl")),
Some(Tag::Long(600..=2400))
));
let Some(Tag::List(attributes)) = tag.get("attributes") else {
panic!("no attributes");
};
assert!(attributes.iter().any(|a| {
matches!(a.get("id"), Some(Tag::String(s)) if s.to_string_lossy() == "minecraft:follow_range")
&& matches!(a.get("modifiers"), Some(Tag::List(m)) if m.len() == 1)
}));
}
fn id(t: Option<&Tag>) -> String {
match t.and_then(|t| t.get("id")) {
Some(Tag::String(s)) => s.to_string_lossy().trim_start_matches("minecraft:").into(),
_ => "-".into(),
}
}
/// What a vanilla world saves of a structure mob that finalizeSpawn decides.
fn describe(mob: &Tag) -> String {
let eq = mob.get("equipment");
let gear: Vec<String> = ["mainhand", "head", "chest", "legs", "feet"]
.iter()
.map(|slot| id(eq.and_then(|e| e.get(slot))))
.collect();
let ttl = match mob
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|m| m.get("minecraft:hunted_recently"))
.and_then(|h| h.get("ttl"))
{
Some(Tag::Long(t)) => t.to_string(),
_ => "-".into(),
};
let bonus = crate::entity_nbt::spawn_bonus(mob).unwrap_or(f64::NAN);
let flag = |k: &str| matches!(mob.get(k), Some(Tag::Byte(1)));
let riders = match mob.get("Passengers") {
Some(Tag::List(p)) => format!(
" [{}]",
p.iter().map(describe).collect::<Vec<_>>().join("; ")
),
_ => String::new(),
};
format!(
"{} {} ttl={ttl} bonus={bonus:.6} left={} baby={}{riders}",
id(Some(mob)),
gear.join(","),
flag("LeftHanded"),
flag("IsBaby") || matches!(mob.get("Age"), Some(Tag::Int(a)) if *a < 0),
)
}
/// The described entities of chunk (x, z) after decorating the 3x3 around it.
fn chunk_mobs(
root: &std::path::Path,
seed: i64,
dimension: &str,
x: i32,
z: i32,
) -> Vec<String> {
use crate::streaming::{ChunkGenerator, Proto};
let assets = crate::assets::Assets::load(root).unwrap();
let biomes = crate::biome::Biomes::load(root).unwrap();
let terrain = super::super::VanillaTerrain::for_dimension(
&root.join("worldgen"),
assets.registry,
&biomes,
seed,
dimension,
)
.unwrap();
let mut worker = terrain.worker();
let mut region: [Proto; 9] = std::array::from_fn(|i| {
terrain.proto(&mut worker, x + i as i32 % 3 - 1, z + i as i32 / 3 - 1)
});
terrain.decorate(&mut worker, &mut region);
let center = std::mem::replace(&mut region[4], Box::new(()));
let mut got: Vec<String> = terrain
.finish(center)
.entities
.iter()
.map(describe)
.collect();
got.sort();
got
}
/// Seed 12345's bastion at chunk (-21, 8): the mobs of chunk (-23, 8) as two
/// vanilla 26.3 runs saved them (weapons and armour, hunting memory, follow
/// range bonus, handedness), from the region random of that chunk. The
/// memories are as placed: vanilla saved them 13 brain ticks later.
#[test]
fn bastion_chunk_mobs_match_vanilla() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES").map(PathBuf::from) else {
return;
};
let got = chunk_mobs(&root, 12345, "minecraft:the_nether", -23, 8);
let mut expected = [
"piglin golden_sword,-,golden_chestplate,-,- ttl=1760 bonus=0.013784",
"piglin crossbow,-,-,-,- ttl=806 bonus=-0.011458",
"piglin golden_sword,-,-,-,- ttl=1315 bonus=-0.084585",
"piglin golden_sword,-,-,-,- ttl=1489 bonus=-0.038292",
"piglin_brute golden_axe,-,-,-,- ttl=- bonus=-0.020574",
"piglin crossbow,-,-,-,- ttl=1620 bonus=-0.079135",
"hoglin -,-,-,-,- ttl=- bonus=-0.012052",
"piglin golden_sword,-,-,-,- ttl=783 bonus=-0.077071",
"piglin crossbow,-,-,golden_leggings,- ttl=1431 bonus=0.007058",
]
.map(|e| format!("{e} left=false baby=false"));
expected.sort();
assert_eq!(got, expected);
}
/// Seed 12345's ocean ruin at (53, 39, 424): Drowned.finalizeSpawn mounts the
/// trident drowned on a zombie nautilus, as vanilla saved chunk (3, 26).
#[test]
fn ocean_ruin_drowned_match_vanilla() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES").map(PathBuf::from) else {
return;
};
let got = chunk_mobs(&root, 12345, "minecraft:overworld", 3, 26);
let d = |gear: &str, bonus: &str| {
format!("drowned {gear},-,-,-,- ttl=- bonus={bonus} left=false baby=false")
};
let mut expected = vec![
d("-", "0.001996"),
format!(
"zombie_nautilus -,-,-,-,- ttl=- bonus=-0.044983 left=false baby=false [{}]",
d("trident", "-0.045604")
),
d("fishing_rod", "-0.059079"),
d("-", "0.021658"),
];
expected.sort();
assert_eq!(got, expected);
}
}
@@ -1,7 +1,7 @@
id = "beacons"
name = "Beacons"
version = "0.1.0"
description = "/beacon lights a pillar of glowstone beside you; /ground says what you are standing on, and in which biome."
description = "/beacon [height] lights a pillar of glowstone beside you; /ground [pos] says what you are standing on (or what is at pos), and in which biome."
authors = ["Horizon"]
api = "0.3"
side = "server"
@@ -1,9 +1,12 @@
//! Example Horizon server mod (`docs/mods.md`, "Server mods") with
//! capability `world`: `/beacon` lights a pillar of glowstone two blocks east
//! of the player, `/ground` reads the block under their feet and the biome.
use horizon_mod_sdk::server::{BlockChange, Player, Registration, ServerMod, players, world};
//! capability `world` and typed commands: `/beacon [height]` lights a
//! pillar of glowstone two blocks east of the player, `/ground [pos]` reads
//! the block under their feet (or at `pos`) and its biome.
use horizon_mod_sdk::server::{
Args, Argument, BlockChange, Player, Registration, ServerMod, players, world,
};
/// How tall `/beacon` builds.
/// How tall `/beacon` builds when no height is given.
const HEIGHT: i32 = 4;
struct Beacons;
@@ -17,10 +20,10 @@ fn feet(player: &Player) -> (i32, i32, i32) {
)
}
/// The pillar beside `player`: `HEIGHT` blocks of glowstone, from their feet up.
fn pillar(player: &Player) -> Vec<BlockChange> {
/// The pillar beside `player`: `height` blocks of glowstone, from their feet up.
fn pillar(player: &Player, height: i32) -> Vec<BlockChange> {
let (x, y, z) = feet(player);
(0..HEIGHT)
(0..height)
.map(|dy| BlockChange {
x: x + 2,
y: y + dy,
@@ -34,7 +37,12 @@ impl ServerMod for Beacons {
fn init() -> Result<(Self, Registration), String> {
Ok((
Beacons,
Registration::new().command("beacon").command("ground"),
Registration::new()
.command_with(
"beacon",
[Argument::integer("height", Some(1), Some(64)).optional()],
)
.command_with("ground", [Argument::block_pos("pos").optional()]),
))
}
@@ -42,11 +50,12 @@ impl ServerMod for Beacons {
&mut self,
player: &Player,
name: &str,
_args: &str,
args: &Args,
) -> Result<Option<String>, String> {
let (x, y, z) = feet(player);
if name == "beacon" {
world::set_blocks(&player.dimension, &pillar(player))?;
let height = args.integer("height").unwrap_or(HEIGHT);
world::set_blocks(&player.dimension, &pillar(player, height))?;
players::broadcast(&format!(
"{} lit a beacon at {} {y} {z}",
player.name,
@@ -54,10 +63,17 @@ impl ServerMod for Beacons {
));
return Ok(None);
}
let below = world::get_block(&player.dimension, x, y - 1, z)
.ok_or("The ground under you isn't loaded")?;
let biome = world::get_biome(&player.dimension, x, y, z).unwrap_or_default();
Ok(Some(format!("You are standing on {below} in {biome}")))
let (bx, by, bz) = match args.block_pos("pos") {
Some(p) => (p.x, p.y, p.z),
None => (x, y - 1, z),
};
let block =
world::get_block(&player.dimension, bx, by, bz).ok_or("That block isn't loaded")?;
let biome = world::get_biome(&player.dimension, bx, by, bz).unwrap_or_default();
if args.block_pos("pos").is_some() {
return Ok(Some(format!("{bx} {by} {bz} is {block} in {biome}")));
}
Ok(Some(format!("You are standing on {block} in {biome}")))
}
}
@@ -79,7 +95,7 @@ mod tests {
game_mode: horizon_mod_sdk::server::GameMode::Survival,
health: 20.0,
};
let blocks = pillar(&p);
let blocks = pillar(&p, HEIGHT);
assert_eq!(blocks.len(), HEIGHT as usize);
assert_eq!((blocks[0].x, blocks[0].y, blocks[0].z), (1, 64, 3));
assert_eq!(blocks[3].y, 67);
@@ -1,4 +1,4 @@
# /ground reads the block under the player and the biome; /beacon sets four blocks of glowstone two to the east.
# /ground [pos] reads the block under the player (or at pos) and the biome; /beacon [height] sets glowstone two to the east.
[[step]]
join = "Alex"
@@ -20,3 +20,20 @@ expect.blocks = [
player = { x = 10.5, z = 10.5 }
command = "ground"
expect.feedback = "You are standing on minecraft:air in minecraft:plains"
# Typed arguments: a height, a position relative to the player.
[[step]]
player = { x = 5.5, y = 64.0, z = 5.5 }
command = "beacon 2"
expect.blocks = [
{ x = 7, y = 65, z = 5, state = "minecraft:glowstone" },
{ x = 7, y = 66, z = 5, state = "minecraft:air" },
]
[[step]]
command = "beacon 65"
expect.error = "Integer must not be more than 64, found 65"
[[step]]
command = "ground ~2 ~1 ~"
expect.feedback = "7 65 5 is minecraft:glowstone in minecraft:plains"
@@ -1,9 +1,10 @@
//! Example Horizon server mod (`docs/mods.md`, "Server mods"): greets each
//! player who joins, announces a newcomer to everyone (the players it has
//! seen are kept in `storage`), `/online` lists who is on and where, and
//! `/mined` counts the blocks the player broke since the server started.
//! `/mined [player]` counts the blocks a player broke since the server
//! started (typed arguments: the chat box completes the player's name).
use horizon_mod_sdk::{
server::{BlockPos, Player, Registration, ServerMod, players},
server::{Args, Argument, BlockPos, Player, Registration, ServerMod, players},
storage,
};
use std::collections::HashMap;
@@ -25,8 +26,8 @@ impl ServerMod for Welcome {
})
.unwrap_or_default();
let hooks = Registration::new()
.command("online")
.command("mined")
.command_with("online", [])
.command_with("mined", [Argument::player("player").optional()])
.on_joins()
.on_blocks();
Ok((
@@ -55,7 +56,7 @@ impl ServerMod for Welcome {
&mut self,
player: &Player,
name: &str,
_args: &str,
args: &Args,
) -> Result<Option<String>, String> {
Ok(Some(match name {
"online" => players::online()
@@ -69,10 +70,16 @@ impl ServerMod for Welcome {
})
.collect::<Vec<_>>()
.join(", "),
_ => {
let n = self.mined.get(&player.id).copied().unwrap_or(0);
format!("You broke {n} block(s)")
}
_ => match args.player("player") {
Some(other) if other.id != player.id => {
let n = self.mined.get(&other.id).copied().unwrap_or(0);
format!("{} broke {n} block(s)", other.name)
}
_ => {
let n = self.mined.get(&player.id).copied().unwrap_or(0);
format!("You broke {n} block(s)")
}
},
}))
}
@@ -1,4 +1,4 @@
# /online lists the players and their dimension; /mined counts the acting player's blocks.
# /online lists the players and their dimension; /mined [player] counts a player's blocks.
[[step]]
join = "Alex"
@@ -29,3 +29,18 @@ expect.feedback = "You broke 2 block(s)"
as = "Steve"
command = "mined"
expect.feedback = "You broke 0 block(s)"
[[step]]
as = "Steve"
command = "mined alex"
expect.feedback = "Alex broke 2 block(s)"
# The server parses the arguments before the mod: vanilla's errors.
[[step]]
as = "Steve"
command = "mined Herobrine"
expect.error = "No player was found"
[[step]]
command = "online now"
expect.error = "Incorrect argument for command"
+127 -8
View File
@@ -194,7 +194,10 @@ pub mod server {
},
});
pub use exports::horizon::mods::server_events::{BlockPos, Guest as __Guest, Registration};
pub use exports::horizon::mods::server_events::{
Argument, ArgumentKind, ArgumentValue, BlockPos, Guest as __Guest, Registration,
ServerCommand,
};
pub use horizon::mods::players::{self, GameMode, Player};
pub use horizon::mods::world::{self, BlockChange};
@@ -211,13 +214,14 @@ pub mod server {
fn player_left(&mut self, _player: &Player) {}
/// A chat message a player sent, when [`Registration::chat`] is set.
fn chat(&mut self, _player: &Player, _text: &str) {}
/// `/name args` typed by `player`. `Ok(Some(text))` is their
/// feedback, `Err(text)` their error in red.
/// `/name args` typed by `player`, parsed as the command's
/// arguments say ([`Registration::command_with`]). `Ok(Some(text))`
/// is their feedback, `Err(text)` their error in red.
fn command(
&mut self,
_player: &Player,
_name: &str,
_args: &str,
_args: &Args,
) -> Result<Option<String>, String> {
Err("This command does nothing".into())
}
@@ -237,9 +241,23 @@ pub mod server {
blocks: false,
}
}
/// Adds `/name`.
pub fn command(mut self, name: &str) -> Self {
self.commands.push(name.into());
/// Adds `/name`, taking free text ([`Args::text`]): one optional
/// `text` argument named `args`.
pub fn command(self, name: &str) -> Self {
self.command_with(name, [Argument::text("args").optional()])
}
/// Adds `/name` with typed arguments (none: nothing after the name):
/// players' chat boxes complete and check them, and the server
/// parses them into [`Args`].
pub fn command_with(
mut self,
name: &str,
arguments: impl IntoIterator<Item = Argument>,
) -> Self {
self.commands.push(ServerCommand {
name: name.into(),
arguments: arguments.into_iter().collect(),
});
self
}
pub fn on_tick(mut self) -> Self {
@@ -264,6 +282,105 @@ pub mod server {
Self::new()
}
}
impl Argument {
fn of(name: &str, kind: ArgumentKind) -> Self {
Self {
name: name.into(),
kind,
optional: false,
}
}
/// One word: letters, digits and `_ - . +`.
pub fn word(name: &str) -> Self {
Self::of(name, ArgumentKind::Word)
}
/// The rest of the line: only last.
pub fn text(name: &str) -> Self {
Self::of(name, ArgumentKind::Text)
}
/// A whole number, at least `min` and at most `max` when given.
pub fn integer(name: &str, min: Option<i32>, max: Option<i32>) -> Self {
Self::of(name, ArgumentKind::Integer((min, max)))
}
pub fn number(name: &str) -> Self {
Self::of(name, ArgumentKind::Number)
}
pub fn boolean(name: &str) -> Self {
Self::of(name, ArgumentKind::Boolean)
}
/// One online player: a name, `@s` or `@p`.
pub fn player(name: &str) -> Self {
Self::of(name, ArgumentKind::Player)
}
/// `x y z`, each one also `~` or `~n`.
pub fn block_pos(name: &str) -> Self {
Self::of(name, ArgumentKind::BlockPos)
}
/// May be left out; optional arguments come after the required ones.
pub fn optional(mut self) -> Self {
self.optional = true;
self
}
}
/// A command's arguments: the line as typed, and the values the server
/// parsed from it, by argument name.
#[derive(Clone, Debug, Default)]
pub struct Args {
text: String,
values: Vec<(String, ArgumentValue)>,
}
impl Args {
pub fn new(text: String, values: Vec<(String, ArgumentValue)>) -> Self {
Self { text, values }
}
/// The line after the command's name, as typed.
pub fn text(&self) -> &str {
&self.text
}
/// The value of argument `name`; `None` when it was left out.
pub fn get(&self, name: &str) -> Option<&ArgumentValue> {
self.values.iter().find(|(n, _)| n == name).map(|(_, v)| v)
}
pub fn integer(&self, name: &str) -> Option<i32> {
match self.get(name)? {
ArgumentValue::Integer(n) => Some(*n),
_ => None,
}
}
pub fn number(&self, name: &str) -> Option<f64> {
match self.get(name)? {
ArgumentValue::Number(n) => Some(*n),
_ => None,
}
}
/// A `word` or `text` argument.
pub fn string(&self, name: &str) -> Option<&str> {
match self.get(name)? {
ArgumentValue::Word(s) | ArgumentValue::Text(s) => Some(s),
_ => None,
}
}
pub fn boolean(&self, name: &str) -> Option<bool> {
match self.get(name)? {
ArgumentValue::Boolean(b) => Some(*b),
_ => None,
}
}
pub fn player(&self, name: &str) -> Option<&Player> {
match self.get(name)? {
ArgumentValue::Player(p) => Some(p),
_ => None,
}
}
pub fn block_pos(&self, name: &str) -> Option<BlockPos> {
match self.get(name)? {
ArgumentValue::BlockPos(p) => Some(*p),
_ => None,
}
}
}
}
/// Exports a [`ServerMod`](server::ServerMod) as the component's
@@ -273,7 +390,7 @@ macro_rules! export_server_mod {
($t:ty) => {
const _: () = {
use ::std::cell::RefCell;
use $crate::server::{BlockPos, Player, Registration, ServerMod};
use $crate::server::{Args, ArgumentValue, BlockPos, Player, Registration, ServerMod};
::std::thread_local! {
static STATE: RefCell<Option<$t>> = const { RefCell::new(None) };
}
@@ -307,8 +424,10 @@ macro_rules! export_server_mod {
player: Player,
name: String,
args: String,
values: Vec<(String, ArgumentValue)>,
) -> Result<Option<String>, String> {
let mut out = Err("The mod is not running".to_string());
let args = Args::new(args, values);
with(|m| out = m.command(&player, &name, &args));
out
}
+58 -10
View File
@@ -680,10 +680,61 @@ interface %world {
interface server-events {
use players.{player};
record block-pos {
x: s32,
y: s32,
z: s32,
}
/// What an argument of a command takes. The server sends each command
/// and its arguments to players' games, whose chat box completes and
/// colours them as vanilla's; the server parses them before the mod's
/// `command` hook gets their values.
variant argument-kind {
/// One word: letters, digits and `_ - . +`.
word,
/// The rest of the line, spaces included: only last.
text,
/// A whole number, within the bounds given.
integer(tuple<option<s32>, option<s32>>),
/// A number with or without decimals.
number,
/// `true` or `false`.
boolean,
/// One online player: a name, or `@s` or `@p` (whoever typed it,
/// as vanilla picks for a player).
player,
/// A block position: three whole numbers, each one also `~` or
/// `~n`, relative to whoever typed it.
block-pos,
}
record argument {
/// Shown in the chat box's hint (`<height>`) and the key of its value.
name: string,
kind: argument-kind,
/// Optional arguments come after every required one.
optional: bool,
}
record server-command {
/// Without the slash. A name vanilla already uses is refused
/// (vanilla's command runs).
name: string,
/// In order; none: the command takes nothing after its name. Free
/// text is one optional `text` argument.
arguments: list<argument>,
}
/// An argument's value, parsed as its kind says.
variant argument-value {
word(string),
text(string),
integer(s32),
number(f64),
boolean(bool),
player(player),
block-pos(block-pos),
}
record registration {
/// Commands players may type, without the slash. A name vanilla
/// already uses is refused (vanilla's command runs).
commands: list<string>,
/// Commands players may type.
commands: list<server-command>,
/// Call `tick` every server tick (20 per second).
tick: bool,
/// Call `player-joined` and `player-left`.
@@ -693,11 +744,6 @@ interface server-events {
/// Call `block-broken` and `block-placed`.
blocks: bool,
}
record block-pos {
x: s32,
y: s32,
z: s32,
}
/// Once, after loading. An error keeps the mod off and is logged.
init: func() -> result<registration, string>;
/// Each server tick, with the server's tick counter.
@@ -709,8 +755,10 @@ interface server-events {
/// A chat message a player sent, as plain text.
chat: func(player: player, text: string);
/// `/name args` typed by `player`: ok(some(text)) is their feedback,
/// err(text) their error in red.
command: func(player: player, name: string, args: string) -> result<option<string>, string>;
/// err(text) their error in red. `values` holds each argument given, by
/// name, in order (an optional one left out isn't there); a line that
/// doesn't parse never reaches the mod: the player gets vanilla's error.
command: func(player: player, name: string, args: string, values: list<tuple<string, argument-value>>) -> result<option<string>, string>;
/// A player broke the block at `pos` in their dimension: `state` is what
/// was there, as `minecraft:oak_log[axis=y]`.
block-broken: func(player: player, pos: block-pos, state: string);
+301
View File
@@ -0,0 +1,301 @@
//! Server mods' typed commands: an argument line parsed as the command's
//! arguments say, the way vanilla's Brigadier parsers read them, with
//! vanilla's error messages. The game sends the same arguments to players'
//! command trees, so their chat box checks them before the server does.
use crate::runtime::{Argument, ArgumentKind, ArgumentValue, BlockPos, ServerPlayer};
/// The values of `args`, typed by `player`, for `arguments` (in order;
/// optional ones left out aren't there), or the error the player reads.
pub fn parse(
arguments: &[Argument],
args: &str,
player: &ServerPlayer,
online: &[ServerPlayer],
) -> Result<Vec<(String, ArgumentValue)>, String> {
let mut values = Vec::new();
let mut rest = args;
for (i, argument) in arguments.iter().enumerate() {
if i > 0 && !rest.is_empty() {
rest = rest.strip_prefix(' ').ok_or(
"Expected whitespace to end one argument, but found trailing data".to_string(),
)?;
}
if rest.is_empty() {
if argument.optional {
break;
}
return Err("Unknown or incomplete command, see below for error".into());
}
let (value, after) = one(&argument.kind, rest, player, online)?;
values.push((argument.name.clone(), value));
rest = after;
}
if !rest.is_empty() {
return Err("Incorrect argument for command".into());
}
Ok(values)
}
/// StringReader.isAllowedInUnquotedString.
fn unquoted(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '+')
}
/// The unquoted string at the start of `s`, and what follows it.
fn word(s: &str) -> (&str, &str) {
let end = s.find(|c: char| !unquoted(c)).unwrap_or(s.len());
s.split_at(end)
}
/// One argument from the start of `s`, and what follows it.
fn one<'a>(
kind: &ArgumentKind,
s: &'a str,
player: &ServerPlayer,
online: &[ServerPlayer],
) -> Result<(ArgumentValue, &'a str), String> {
Ok(match kind {
ArgumentKind::Word => {
let (w, rest) = word(s);
if w.is_empty() {
return Err("Expected string".into());
}
(ArgumentValue::Word(w.into()), rest)
}
ArgumentKind::Text => (ArgumentValue::Text(s.into()), ""),
ArgumentKind::Integer((min, max)) => {
let (n, rest) = integer(s)?;
if let Some(min) = min.filter(|m| n < *m) {
return Err(format!("Integer must not be less than {min}, found {n}"));
}
if let Some(max) = max.filter(|m| n > *m) {
return Err(format!("Integer must not be more than {max}, found {n}"));
}
(ArgumentValue::Integer(n), rest)
}
ArgumentKind::Number => {
let (n, rest) = double(s)?;
(ArgumentValue::Number(n), rest)
}
ArgumentKind::Boolean => {
let (w, rest) = word(s);
let b = match w {
"true" => true,
"false" => false,
"" => return Err("Expected bool".into()),
_ => {
return Err(format!(
"Invalid boolean, expected 'true' or 'false' but found '{w}'"
));
}
};
(ArgumentValue::Boolean(b), rest)
}
ArgumentKind::Player => {
if let Some(selector) = s.strip_prefix('@') {
let (kind, rest) = word(selector);
return match kind {
// A player's @p is themselves (the nearest to where they are).
"s" | "p" => Ok((ArgumentValue::Player(player.clone()), rest)),
"a" | "e" | "r" | "n" => {
Err("Only one player is allowed, but the provided selector allows more than one".into())
}
_ => Err(format!("Unknown selector type '@{kind}'")),
};
}
let (name, rest) = word(s);
if name.is_empty() {
return Err("Invalid name or UUID".into());
}
let found = online
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
.ok_or("No player was found")?;
(ArgumentValue::Player(found.clone()), rest)
}
ArgumentKind::BlockPos => {
let (x, rest) = coordinate(s, player.x)?;
let rest = rest
.strip_prefix(' ')
.ok_or("Incomplete (expected 3 coordinates)")?;
let (y, rest) = coordinate(rest, player.y)?;
let rest = rest
.strip_prefix(' ')
.ok_or("Incomplete (expected 3 coordinates)")?;
let (z, rest) = coordinate(rest, player.z)?;
(ArgumentValue::BlockPos(BlockPos { x, y, z }), rest)
}
})
}
/// StringReader.readInt.
fn integer(s: &str) -> Result<(i32, &str), String> {
let end = s
.find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
.unwrap_or(s.len());
let (n, rest) = s.split_at(end);
if n.is_empty() {
return Err("Expected integer".into());
}
let n = n.parse().map_err(|_| format!("Invalid integer '{n}'"))?;
Ok((n, rest))
}
/// StringReader.readDouble.
fn double(s: &str) -> Result<(f64, &str), String> {
let end = s
.find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
.unwrap_or(s.len());
let (n, rest) = s.split_at(end);
if n.is_empty() {
return Err("Expected double".into());
}
let n = n.parse().map_err(|_| format!("Invalid double '{n}'"))?;
Ok((n, rest))
}
/// WorldCoordinate.parseInt, then BlockPos.containing: `~` and `~n` from
/// `base`, a whole number as it is.
fn coordinate(s: &str, base: f64) -> Result<(i32, &str), String> {
if s.starts_with('^') {
return Err(
"Cannot mix world & local coordinates (everything must either use ^ or not)".into(),
);
}
if let Some(offset) = s.strip_prefix('~') {
if offset.is_empty() || offset.starts_with(' ') {
return Ok((base.floor() as i32, offset));
}
let (n, rest) = double(offset)?;
return Ok(((base + n).floor() as i32, rest));
}
integer(s)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::GameMode;
fn player(name: &str) -> ServerPlayer {
ServerPlayer {
id: format!("id-{name}"),
name: name.into(),
x: 10.7,
y: 64.0,
z: -3.2,
dimension: "minecraft:overworld".into(),
game_mode: GameMode::Survival,
health: 20.0,
}
}
fn arg(name: &str, kind: ArgumentKind, optional: bool) -> Argument {
Argument {
name: name.into(),
kind,
optional,
}
}
#[test]
fn values_in_order_and_optional_ones_left_out() {
let alex = player("Alex");
let online = [alex.clone(), player("Steve")];
let args = [
arg("who", ArgumentKind::Player, false),
arg("height", ArgumentKind::Integer((Some(1), Some(64))), true),
arg("note", ArgumentKind::Text, true),
];
let v = parse(&args, "steve 5 hello there", &alex, &online).unwrap();
assert_eq!(v[0].0, "who");
assert!(matches!(&v[0].1, ArgumentValue::Player(p) if p.name == "Steve"));
assert_eq!(v[1], ("height".into(), ArgumentValue::Integer(5)));
assert_eq!(
v[2],
("note".into(), ArgumentValue::Text("hello there".into()))
);
let v = parse(&args, "@s", &alex, &online).unwrap();
assert_eq!(v.len(), 1);
assert!(matches!(&v[0].1, ArgumentValue::Player(p) if p.name == "Alex"));
}
#[test]
fn vanilla_errors() {
let alex = player("Alex");
let online = [alex.clone()];
let height = [arg(
"height",
ArgumentKind::Integer((Some(1), Some(64))),
false,
)];
let err = |a: &[Argument], s| parse(a, s, &alex, &online).unwrap_err();
assert_eq!(
err(&height, "0"),
"Integer must not be less than 1, found 0"
);
assert_eq!(
err(&height, "65"),
"Integer must not be more than 64, found 65"
);
assert_eq!(err(&height, "x"), "Expected integer");
assert_eq!(err(&height, "1.5"), "Invalid integer '1.5'");
assert_eq!(
err(&height, ""),
"Unknown or incomplete command, see below for error"
);
assert_eq!(err(&height, "3 4"), "Incorrect argument for command");
let who = [arg("who", ArgumentKind::Player, false)];
assert_eq!(err(&who, "Herobrine"), "No player was found");
assert_eq!(err(&who, "@q"), "Unknown selector type '@q'");
let flag = [arg("on", ArgumentKind::Boolean, false)];
assert_eq!(
err(&flag, "yes"),
"Invalid boolean, expected 'true' or 'false' but found 'yes'"
);
let pos = [arg("at", ArgumentKind::BlockPos, false)];
assert_eq!(err(&pos, "1 2"), "Incomplete (expected 3 coordinates)");
}
#[test]
fn block_positions_relative_to_the_player() {
let alex = player("Alex");
let pos = [arg("at", ArgumentKind::BlockPos, false)];
let v = parse(&pos, "~ ~1 ~-0.5", &alex, &[]).unwrap();
assert_eq!(
v[0].1,
ArgumentValue::BlockPos(BlockPos {
x: 10,
y: 65,
z: -4
})
);
let v = parse(&pos, "-5 70 ~", &alex, &[]).unwrap();
assert_eq!(
v[0].1,
ArgumentValue::BlockPos(BlockPos {
x: -5,
y: 70,
z: -4
})
);
}
#[test]
fn free_text_is_an_optional_text_argument() {
let alex = player("Alex");
let free = [arg("args", ArgumentKind::Text, true)];
assert_eq!(parse(&free, "", &alex, &[]), Ok(Vec::new()));
assert_eq!(
parse(&free, "anything at all", &alex, &[]),
Ok(vec![(
"args".into(),
ArgumentValue::Text("anything at all".into())
)])
);
assert_eq!(
parse(&[], "more", &alex, &[]),
Err("Incorrect argument for command".into())
);
assert_eq!(parse(&[], "", &alex, &[]), Ok(Vec::new()));
}
}
+2
View File
@@ -6,6 +6,8 @@
//! Nothing here runs unless a mod is installed and turned on: an empty
//! [`Mods`] makes no engine and every hook returns at once.
pub mod api_level;
#[cfg(feature = "runtime")]
pub mod commands;
pub mod content;
#[cfg(feature = "index")]
pub mod index;
+98 -24
View File
@@ -52,7 +52,9 @@ use bindings::{
horizon::mods::{chat, command, game, hud, log as mod_log, storage},
};
pub use server_bindings::{
exports::horizon::mods::server_events::BlockPos,
exports::horizon::mods::server_events::{
Argument, ArgumentKind, ArgumentValue, BlockPos, ServerCommand,
},
horizon::mods::players::{GameMode, Player as ServerPlayer},
horizon::mods::world::BlockChange,
};
@@ -336,10 +338,10 @@ impl Hooks {
Hooks::Client(..) => None,
}
}
fn commands(&self) -> &[String] {
fn commands(&self) -> Vec<&str> {
match self {
Hooks::Client(_, r) => &r.commands,
Hooks::Server(_, r) => &r.commands,
Hooks::Client(_, r) => r.commands.iter().map(String::as_str).collect(),
Hooks::Server(_, r) => r.commands.iter().map(|c| c.name.as_str()).collect(),
}
}
}
@@ -434,9 +436,16 @@ pub struct Mods {
snapshot: Arc<Snapshot>,
out: Vec<Output>,
world: WorldSlot,
/// Moves on whenever a mod starts or stops: the game resends the server
/// mods' commands to players when it changes.
generation: u64,
}
impl Mods {
/// Changes whenever the running mods (so their commands) change.
pub fn generation(&self) -> u64 {
self.generation
}
/// Whether a running mod may use capability `world`: only then is a
/// view worth lending.
pub fn wants_world(&self) -> bool {
@@ -571,7 +580,8 @@ impl Mods {
.iter()
.filter_map(|e| e.run.as_ref())
.filter(|r| r.hooks.server().is_some() == server)
.flat_map(|r| r.hooks.commands().iter().cloned())
.flat_map(|r| r.hooks.commands())
.map(str::to_owned)
.collect();
let snapshot = self.snapshot.clone();
let world = self.world.clone();
@@ -640,17 +650,30 @@ impl Mods {
.map_err(|e| reason(&e))??;
(Hooks::Client(exports, r), ui)
};
let commands = match &mut hooks {
Hooks::Client(_, r) => &mut r.commands,
Hooks::Server(_, r) => &mut r.commands,
};
commands.retain(|c| {
let free = !reserved(c) && !taken.contains(c);
let free = |c: &str| {
let free = !reserved(c) && !taken.iter().any(|t| t == c);
if !free {
log::warn!("Horizon: mod {} cannot take /{c}: already a command", m.id);
}
free
});
};
match &mut hooks {
Hooks::Client(_, r) => r.commands.retain(|c| free(c)),
Hooks::Server(_, r) => {
r.commands.retain(|c| free(&c.name));
for c in &mut r.commands {
if let Err(e) = check_arguments(&c.arguments) {
log::warn!(
"Horizon: mod {}'s /{}: {e}; it takes free text",
m.id,
c.name
);
c.arguments.clear();
}
}
}
}
self.generation += 1;
self.out.append(&mut store.data_mut().out);
Ok(Some(Running {
store,
@@ -768,6 +791,7 @@ impl Mods {
});
entry.status = Status::Failed(reason);
entry.run = None;
self.generation += 1;
None
}
}
@@ -792,7 +816,7 @@ impl Mods {
fn command_owner(&self, server: bool, name: &str) -> Option<usize> {
self.entries.iter().position(|e| {
e.run.as_ref().is_some_and(|r| {
r.hooks.server().is_some() == server && r.hooks.commands().iter().any(|c| c == name)
r.hooks.server().is_some() == server && r.hooks.commands().contains(&name)
})
})
}
@@ -825,7 +849,7 @@ impl Mods {
.iter()
.filter_map(|e| e.run.as_ref())
.filter(move |r| r.hooks.server().is_some() == server)
.flat_map(|r| r.hooks.commands().iter().map(String::as_str))
.flat_map(|r| r.hooks.commands())
}
/// A server tick.
@@ -868,9 +892,13 @@ impl Mods {
|m, s| m.call_block_placed(s, player, pos, state),
);
}
/// The commands running server mods own, for the server's command tree.
pub fn server_commands(&self) -> impl Iterator<Item = &str> {
self.owned_commands(true)
/// The commands running server mods own and their arguments, for the
/// server's command tree.
pub fn server_commands(&self) -> impl Iterator<Item = &ServerCommand> {
self.entries
.iter()
.filter_map(|e| e.run.as_ref()?.hooks.server())
.flat_map(|r| r.commands.iter())
}
/// Runs `line` (without its slash), typed by `player`, if a server mod
/// owns its command; its feedback is an [`Output::Reply`]. False when
@@ -880,18 +908,35 @@ impl Mods {
let Some(i) = self.command_owner(true, name) else {
return false;
};
let result = self.call(i, FUEL_EVENT, |run| match &run.hooks {
Hooks::Server(m, _) => {
m.horizon_mods_server_events()
.call_command(&mut run.store, player, name, args)
}
Hooks::Client(..) => Ok(Ok(None)),
});
let reply = |text, error| Output::Reply {
player: player.id.clone(),
text,
error,
};
let arguments = self.entries[i]
.run
.as_ref()
.and_then(|r| r.hooks.server())
.and_then(|r| r.commands.iter().find(|c| c.name == name))
.map(|c| c.arguments.as_slice())
.unwrap_or_default();
let values = match crate::commands::parse(arguments, args, player, &self.snapshot.players) {
Ok(values) => values,
Err(e) => {
self.out.push(reply(e, true));
return true;
}
};
let result = self.call(i, FUEL_EVENT, |run| match &run.hooks {
Hooks::Server(m, _) => m.horizon_mods_server_events().call_command(
&mut run.store,
player,
name,
args,
&values,
),
Hooks::Client(..) => Ok(Ok(None)),
});
match result {
Some(Ok(Some(text))) => self.out.push(reply(text, false)),
Some(Err(text)) => self.out.push(reply(text, true)),
@@ -1217,6 +1262,7 @@ impl Mods {
continue;
}
any = true;
self.generation += 1;
self.entries[i].modified = now;
self.entries[i].run = None;
let path = self.entries[i].path.clone();
@@ -1242,6 +1288,34 @@ impl Mods {
}
}
/// A command's arguments are well formed: names given and distinct,
/// optional ones last, free text only last.
fn check_arguments(arguments: &[Argument]) -> Result<(), String> {
for (i, a) in arguments.iter().enumerate() {
if a.name.is_empty()
|| !a
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(format!("argument name '{}' isn't [A-Za-z0-9_]+", a.name));
}
if arguments[..i].iter().any(|b| b.name == a.name) {
return Err(format!("two arguments are named {}", a.name));
}
if i > 0 && arguments[i - 1].optional && !a.optional {
return Err(format!("{} is required after an optional argument", a.name));
}
if a.kind == ArgumentKind::Text && i + 1 < arguments.len() {
return Err(format!(
"{} takes the rest of the line, so it must be last",
a.name
));
}
}
Ok(())
}
fn modified(path: &Path) -> Option<SystemTime> {
let meta = std::fs::metadata(path).ok()?;
if meta.is_dir() {
+1 -1
View File
@@ -253,7 +253,7 @@ impl TestGame {
}
/// The commands the mod registered.
pub fn commands(&self) -> Vec<String> {
let server = self.mods.server_commands();
let server = self.mods.server_commands().map(|c| c.name.as_str());
self.mods
.commands()
.chain(server)
+7 -5
View File
@@ -900,7 +900,7 @@ fn a_server_mod_without_a_world_reads_nothing() {
assert!(
out.iter()
.any(|o| matches!(o, Output::Reply { text, error: true, .. }
if text == "The ground under you isn't loaded")),
if text == "That block isn't loaded")),
"{out:?}"
);
}
@@ -999,10 +999,12 @@ fn a_server_mod_runs_only_where_the_world_does() {
let mut mods = Mods::load_for(Runs::Server, paths, |_| true, |c| c == "give");
assert!(mods.runs_server());
assert_eq!(
mods.server_commands().collect::<Vec<_>>(),
["online", "mined"]
);
let commands: Vec<_> = mods.server_commands().map(|c| c.name.as_str()).collect();
assert_eq!(commands, ["online", "mined"]);
// `/mined [player]`: its argument travels with it, for the command tree.
let mined = mods.server_commands().nth(1).unwrap();
assert_eq!(mined.arguments[0].kind, horizon_mods::ArgumentKind::Player);
assert!(mined.arguments[0].optional);
assert_eq!(mods.commands().count(), 0, "not the window's chat commands");
let alex = alex();
mods.set_snapshot(Snapshot {
@@ -29,7 +29,7 @@
//! overlap), so a chunk may be decorated as soon as no lower-ranked chunk
//! within two of it is still waiting: [`DecorationOrder::blocked`]. Chunks
//! further apart still decorate in parallel.
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
#[derive(Default)]
pub struct DecorationOrder {
@@ -121,7 +121,6 @@ fn ticket_hash(x: i32, z: i32) -> u64 {
/// The chunks decorated around a player at `center`, in vanilla's order.
pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
use std::cmp::Reverse;
use std::collections::{BinaryHeap, VecDeque};
let mut tickets: Vec<(i32, i32)> = (-radius..=radius)
.flat_map(|dx| (-radius..=radius).map(move |dz| (center.0 + dx, center.1 + dz)))
.collect();
@@ -132,16 +131,31 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
Reverse(ticket_hash(x, z) & (TICKET_SET_CAPACITY - 1)),
)
});
let mut tickets: VecDeque<_> = tickets.into();
// Every chunk this touches lies within `half` of the centre: tickets
// reach `LIGHT_LEVEL - TICKET_LEVEL` further, and FULL tasks decorate
// one more ring. Dense grids over that square replace hash maps.
// one more ring.
let half = radius.max(0) + LIGHT_LEVEL - TICKET_LEVEL + 1;
ticket_order(
tickets.into(),
TICKETS_IN_FLIGHT,
(center.0 - half, center.1 - half),
(2 * half + 1) as usize,
)
}
/// The decoration order of `tickets` (level 31) added in turn, at most
/// `in_flight` at a time. Dense grids of `side` chunks from `origin` hold
/// every chunk this touches, in place of hash maps.
fn ticket_order(
mut tickets: VecDeque<(i32, i32)>,
in_flight: usize,
origin: (i32, i32),
side: usize,
) -> Vec<(i32, i32)> {
use std::cmp::Reverse;
use std::collections::BinaryHeap;
let reach = LIGHT_LEVEL - TICKET_LEVEL;
let half = radius.max(0) + reach + 1;
let side = (2 * half + 1) as usize;
let cell = |(x, z): (i32, i32)| {
((x - center.0 + half) as usize) * side + (z - center.1 + half) as usize
};
let cell = |(x, z): (i32, i32)| ((x - origin.0) as usize) * side + (z - origin.1) as usize;
let mut active: Vec<(i32, i32)> = Vec::new();
// Queued task per chunk (level, sequence), `NONE` when there is none;
// stale heap entries are skipped.
@@ -177,10 +191,9 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
}
}
};
for _ in 0..TICKETS_IN_FLIGHT {
if let Some(t) = tickets.pop_front() {
add(t, &mut active, &mut queued, &mut heap, &full);
}
while active.len() < in_flight {
let Some(t) = tickets.pop_front() else { break };
add(t, &mut active, &mut queued, &mut heap, &full);
}
while let Some(Reverse((l, s, chunk))) = heap.pop() {
let i = cell(chunk);
@@ -202,7 +215,7 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
full[i] = true;
}
active.retain(|t| !(-2..=2).all(|dx| (-2..=2).all(|dz| full[cell((t.0 + dx, t.1 + dz))])));
while active.len() < TICKETS_IN_FLIGHT {
while active.len() < in_flight {
let Some(t) = tickets.pop_front() else { break };
add(t, &mut active, &mut queued, &mut heap, &full);
}
@@ -210,10 +223,36 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
order
}
/// The chunks `/forceload add` over chunks `(x0, z0)..=(x1, z1)` decorates,
/// in vanilla's order: ForceLoadCommand adds a FORCED ticket (level 31) per
/// chunk, x-outer, z-inner, all at once.
pub fn forced_order(x0: i32, z0: i32, x1: i32, z1: i32) -> Vec<(i32, i32)> {
let tickets = (x0..=x1)
.flat_map(|x| (z0..=z1).map(move |z| (x, z)))
.collect();
let pad = LIGHT_LEVEL - TICKET_LEVEL + 1;
let side = ((x1 - x0).max(z1 - z0) + 2 * pad + 1) as usize;
ticket_order(tickets, usize::MAX, (x0 - pad, z0 - pad), side)
}
#[cfg(test)]
mod tests {
use super::*;
/// Seed 8675309's geodes in chunks (-26, -1) and (14, -20) run into the
/// trial chambers of (-26, 0) and (14, -19); vanilla decorates the lower
/// z first, wherever the batch lies, so the chambers are drawn over them.
#[test]
fn forced_chunks_decorate_x_outer_z_inner() {
let rank = |order: &[(i32, i32)], c| order.iter().position(|&o| o == c).unwrap();
let west = forced_order(-32, -16, -17, -1);
assert!(rank(&west, (-26, -1)) < rank(&west, (-26, 0)));
let east = forced_order(0, -32, 15, -17);
assert!(rank(&east, (14, -20)) < rank(&east, (14, -19)));
assert_eq!(west[0], (-33, -17));
assert_eq!(west.len(), 22 * 22);
}
/// The hash map version this module had before its dense grids.
fn hashed_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> {
use std::cmp::Reverse;
+56 -13
View File
@@ -23,6 +23,7 @@ use super::{
climate::{ClimateSampler, decode_parameter, quantize},
decode_noise,
density::DensityRegistry,
generator_structures::Starts,
material_condition::{
MaterialConditionRegistry, MaterialConditions, MaterialDensitySources, MaterialRule,
MaterialRuleError, MaterialRuleLibrary, MaterialSurfaceQueries,
@@ -567,6 +568,9 @@ pub struct ProtoChunk {
pub block_entities: Vec<([i32; 3], crate::nbt::Compound)>,
/// Structure starts referencing this chunk (STRUCTURE_REFERENCES).
pub(super) structure_starts: Vec<Arc<super::structure::start::StructureStart>>,
/// The starts made in this chunk (STRUCTURE_STARTS), also those none of
/// whose pieces reach it (a giant ruined portal next door).
own_starts: Starts,
/// Entities placed by structures (`ProtoChunk.addEntity`), as NBT with
/// absolute `Pos`.
pub entities: Vec<crate::nbt::Compound>,
@@ -620,12 +624,7 @@ impl ProtoChunk {
}
/// The structure starts this chunk holds itself (`structures.starts`).
pub fn own_starts(&self) -> Vec<Arc<super::structure::start::StructureStart>> {
let pos = self.position();
self.structure_starts
.iter()
.filter(|s| s.chunk == pos)
.cloned()
.collect()
self.own_starts.to_vec()
}
/// Also restores a saved proto chunk that was decorated before it was saved.
pub fn set_decorated(&mut self) {
@@ -1114,16 +1113,19 @@ where
)
.map_err(stage("noise"))?;
chunk.set_generation_stage(PreLightChunkStage::Biomes);
let references = match &self.structures {
Some(structures) => structures.references(
&mut StructureQueries {
let (references, own_starts) = match &self.structures {
Some(structures) => {
let mut queries = StructureQueries {
pipeline: self,
worker,
valid: BTreeSet::new(),
},
pos,
),
None => Vec::new(),
};
(
structures.references(&mut queries, pos),
structures.starts_at(&mut queries, pos),
)
}
None => (Vec::new(), Starts::default()),
};
if let Some(beard) = self
.structures
@@ -1259,6 +1261,7 @@ where
decorated: false,
block_entities: Vec::new(),
structure_starts: references,
own_starts,
entities: Vec::new(),
})
}
@@ -1648,6 +1651,22 @@ pub(super) struct WorkerState {
heights: HashMap<(i32, i32, u8), i32>,
/// Decorate with structure pieces only (for structure parity checks).
pub(super) structures_only: bool,
pub(super) structure_mobs: Option<Arc<dyn StructureMobs>>,
}
/// `Mob.finalizeSpawn(level, difficulty, STRUCTURE, null)` of the entities a
/// structure places with finalizeEntities, run by the game, which owns mobs.
pub trait StructureMobs: Send + Sync {
/// Finalizes the entity in `nbt` if it is a mob, drawing from the
/// region's random. `biome` is the biome at its block, `structures` the
/// structures with a piece there. False when it could not: the entity
/// keeps its `horizon:finalize` for the game to finish on load.
fn finalize(
&self,
nbt: &mut crate::nbt::Compound,
biome: &str,
structures: &[String],
random: &mut dyn super::structure::legacy::PieceRandom,
) -> bool;
}
trait SeededPipeline: Send + Sync {
@@ -2464,6 +2483,7 @@ impl OverworldGenerator {
carver_biomes: HashMap::new(),
timings: StageTimings::default(),
structures_only: false,
structure_mobs: None,
heights: HashMap::new(),
},
}
@@ -2590,6 +2610,9 @@ impl GeneratorWorker {
pub fn set_structures_only(&mut self, only: bool) {
self.state.structures_only = only;
}
pub fn set_structure_mobs(&mut self, mobs: Arc<dyn StructureMobs>) {
self.state.structure_mobs = Some(mobs);
}
}
#[cfg(test)]
@@ -2975,6 +2998,26 @@ mod tests {
}
}
/// Seed 8675309's giant ruined portal starts in chunk (-26, 23) and lies
/// wholly in (-26, 24): its own chunk still holds the start, as vanilla's
/// getAllStarts does, though no piece reaches it.
#[test]
fn a_chunk_holds_its_starts_whose_pieces_lie_elsewhere() {
let Some(data) = prepared_data() else {
return;
};
let generator = OverworldGenerator::new(data, 8675309).expect("generator");
let mut worker = generator.worker();
let proto = worker
.generate_proto(ChunkPos::new(-26, 23))
.expect("proto");
let own: Vec<_> = proto.own_starts().iter().map(|s| s.name.clone()).collect();
assert!(
own.iter().any(|n| n == "minecraft:ruined_portal_mountain"),
"{own:?}"
);
}
/// Chunks no ring search can end near are decided without the rings,
/// which stay unsearched, and get the starts they get with them.
#[test]
@@ -310,6 +310,7 @@ impl Decoration {
Err(e) => return Err(resource(e.to_string())),
};
let structures_only = worker.structures_only;
let references = region[4].structure_starts.clone();
let mut block_entities = Vec::new();
for chunk in region.iter_mut() {
block_entities.append(&mut chunk.block_entities);
@@ -318,6 +319,9 @@ impl Decoration {
block_entities,
entities: Vec::new(),
level_random,
structure_mobs: worker.structure_mobs.clone(),
starts: references.clone(),
biome_names: data.biome_names(),
blocks: data
.feature_resources
.as_ref()
@@ -334,7 +338,6 @@ impl Decoration {
};
let mut stats = DecorateStats::default();
let blocks = self.blocks;
let references = region[4].structure_starts.clone();
// ChunkGenerator.getWritableArea: the chunk's minimum Y + 1 to its maximum Y.
let chunk_box = BoundingBox::new(
[center.x * 16, min_y + 1, center.z * 16],
@@ -419,15 +422,11 @@ impl Decoration {
}
// WorldGenRegion.addFreshEntity: into the chunk holding the position.
for nbt in std::mem::take(&mut world.entities) {
let Some(crate::nbt::Tag::List(p)) = nbt.get(&"Pos".into()) else {
let Some(at) = block_of(&nbt) else {
continue;
};
let coord = |i: usize| match p.get(i) {
Some(crate::nbt::Tag::Double(b)) => f64::from_bits(*b).floor() as i32,
_ => 0,
};
let index = ((coord(2) >> 4).wrapping_sub(center.z) + 1) * 3
+ (coord(0) >> 4).wrapping_sub(center.x)
let index = ((at[2] >> 4).wrapping_sub(center.z) + 1) * 3
+ (at[0] >> 4).wrapping_sub(center.x)
+ 1;
if let Some(chunk) = usize::try_from(index).ok().and_then(|i| region.get_mut(i)) {
chunk.entities.push(nbt);
@@ -454,6 +453,9 @@ struct World<'a> {
block_entities: Vec<([i32; 3], crate::nbt::Compound)>,
entities: Vec<crate::nbt::Compound>,
level_random: &'a mut dyn super::structure::legacy::PieceRandom,
structure_mobs: Option<Arc<dyn super::generator::StructureMobs>>,
starts: Vec<Arc<super::structure::start::StructureStart>>,
biome_names: &'a [Arc<str>],
blocks: Option<&'a BlockBehavior>,
region: TerrainRegion<'static, GenBlockStates>,
biomes: BiomeRegion<BiomeId>,
@@ -847,10 +849,57 @@ impl PlacementContext for World<'_> {
(0..ny).all(|y| (0..nz).all(|z| shape.is_full(x as i32, y as i32, z as i32)))
}))
}
fn add_entity(&mut self, nbt: crate::nbt::Compound) -> Result<(), PlacementQueryError> {
fn add_entity(&mut self, mut nbt: crate::nbt::Compound) -> Result<(), PlacementQueryError> {
if let Some(mobs) = self.structure_mobs.clone()
&& nbt.contains_key(&"horizon:finalize".into())
{
let at = block_of(&nbt).unwrap_or([0; 3]);
let biome = self
.biome_identity(at)
.ok()
.and_then(|b| self.biome_names.get(b.0 as usize))
.map_or("", |n| &**n)
.to_owned();
let structures: Vec<String> = self
.starts
.iter()
.filter(|s| s.pieces.iter().any(|p| p.bounding_box().is_inside(at)))
.map(|s| s.name.clone())
.collect();
if mobs.finalize(&mut nbt, &biome, &structures, self.level_random) {
nbt.remove(&"horizon:finalize".into());
}
}
self.entities.push(nbt);
Ok(())
}
/// SpikeFeature.placeSpike: the crystal is a fresh entity of the region,
/// saved with the chunk.
fn spawn_end_crystal(
&mut self,
position: [f64; 3],
yaw: f32,
beam_target: Option<[i32; 3]>,
invulnerable: bool,
) -> Result<(), PlacementQueryError> {
use crate::nbt::Tag;
let mut nbt = crate::nbt::Compound::new();
nbt.insert("id".into(), Tag::String("minecraft:end_crystal".into()));
nbt.insert(
"Pos".into(),
Tag::List(position.iter().map(|v| Tag::Double(v.to_bits())).collect()),
);
nbt.insert(
"Rotation".into(),
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(0)]),
);
nbt.insert("Invulnerable".into(), Tag::Byte(i8::from(invulnerable)));
nbt.insert("ShowBottom".into(), Tag::Byte(1));
if let Some(b) = beam_target {
nbt.insert("beam_target".into(), Tag::IntArray(b.to_vec()));
}
self.add_entity(nbt)
}
fn set_block_entity(
&mut self,
position: [i32; 3],
@@ -1167,3 +1216,14 @@ impl PlacementContext for World<'_> {
Ok(Some(false))
}
}
/// The block an entity's `Pos` is in.
fn block_of(nbt: &crate::nbt::Compound) -> Option<[i32; 3]> {
let Some(crate::nbt::Tag::List(p)) = nbt.get(&"Pos".into()) else {
return None;
};
Some(std::array::from_fn(|i| match p.get(i) {
Some(crate::nbt::Tag::Double(b)) => f64::from_bits(*b).floor() as i32,
_ => 0,
}))
}
@@ -31,14 +31,19 @@ pub type Markers = Vec<(String, [i32; 3])>;
pub trait PieceRandom: ProcessorRandom {
fn boolean(&mut self) -> bool;
fn double(&mut self) -> f64;
/// The concrete random, for callers that need all of `RandomSource`.
fn as_any(&mut self) -> &mut dyn std::any::Any;
}
impl<R: RandomSource> PieceRandom for R {
impl<R: RandomSource + 'static> PieceRandom for R {
fn boolean(&mut self) -> bool {
self.next_boolean()
}
fn double(&mut self) -> f64 {
self.next_double()
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}
/// Object-safe world access for piece post-processing.
+54 -11
View File
@@ -689,16 +689,6 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On
`type player = player`
### record `registration`
| Field | Type | |
| --- | --- | --- |
| `commands` | `list<string>` | Commands players may type, without the slash. A name vanilla already uses is refused (vanilla's command runs). |
| `tick` | `bool` | Call `tick` every server tick (20 per second). |
| `joins` | `bool` | Call `player-joined` and `player-left`. |
| `chat` | `bool` | Call `chat` for each chat message a player sends. |
| `blocks` | `bool` | Call `block-broken` and `block-placed`. |
### record `block-pos`
| Field | Type | |
@@ -707,6 +697,59 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On
| `y` | `s32` | |
| `z` | `s32` | |
### variant `argument-kind`
What an argument of a command takes. The server sends each command and its arguments to players' games, whose chat box completes and colours them as vanilla's; the server parses them before the mod's `command` hook gets their values.
| Case | Payload | |
| --- | --- | --- |
| `word` | | One word: letters, digits and `_ - . +`. |
| `text` | | The rest of the line, spaces included: only last. |
| `integer` | `tuple<option<s32>, option<s32>>` | A whole number, within the bounds given. |
| `number` | | A number with or without decimals. |
| `boolean` | | `true` or `false`. |
| `player` | | One online player: a name, or `@s` or `@p` (whoever typed it, as vanilla picks for a player). |
| `block-pos` | | A block position: three whole numbers, each one also `~` or `~n`, relative to whoever typed it. |
### record `argument`
| Field | Type | |
| --- | --- | --- |
| `name` | `string` | Shown in the chat box's hint (`<height>`) and the key of its value. |
| `kind` | `argument-kind` | |
| `optional` | `bool` | Optional arguments come after every required one. |
### record `server-command`
| Field | Type | |
| --- | --- | --- |
| `name` | `string` | Without the slash. A name vanilla already uses is refused (vanilla's command runs). |
| `arguments` | `list<argument>` | In order; none: the command takes nothing after its name. Free text is one optional `text` argument. |
### variant `argument-value`
An argument's value, parsed as its kind says.
| Case | Payload | |
| --- | --- | --- |
| `word` | `string` | |
| `text` | `string` | |
| `integer` | `s32` | |
| `number` | `f64` | |
| `boolean` | `bool` | |
| `player` | `player` | |
| `block-pos` | `block-pos` | |
### record `registration`
| Field | Type | |
| --- | --- | --- |
| `commands` | `list<server-command>` | Commands players may type. |
| `tick` | `bool` | Call `tick` every server tick (20 per second). |
| `joins` | `bool` | Call `player-joined` and `player-left`. |
| `chat` | `bool` | Call `chat` for each chat message a player sends. |
| `blocks` | `bool` | Call `block-broken` and `block-placed`. |
| Function | Signature | What it does |
| --- | --- | --- |
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is logged. |
@@ -714,6 +757,6 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On
| `player-joined` | `func(player: player)` | A player joined the server; they are in `players.online` already. |
| `player-left` | `func(player: player)` | A player left the server; they are no longer in `players.online`. |
| `chat` | `func(player: player, text: string)` | A chat message a player sent, as plain text. |
| `command` | `func(player: player, name: string, args: string) -> result<option<string>, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. |
| `command` | `func(player: player, name: string, args: string, values: list<tuple<string, argument-value>>) -> result<option<string>, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. `values` holds each argument given, by name, in order (an optional one left out isn't there); a line that doesn't parse never reaches the mod: the player gets vanilla's error. |
| `block-broken` | `func(player: player, pos: block-pos, state: string)` | A player broke the block at `pos` in their dimension: `state` is what was there, as `minecraft:oak_log[axis=y]`. |
| `block-placed` | `func(player: player, pos: block-pos, state: string)` | A player placed `state` at `pos` in their dimension. |
+7 -4
View File
@@ -18,7 +18,7 @@ the player before the mod is turned on, and checked against the component's real
| Hooks a mod exports | 5: `init`, `tick`, `frame`, `command`, `chat`; a server mod 8: `init`, `tick`, `player-joined`, `player-left`, `chat`, `command`, `block-broken`, `block-placed` |
| Capabilities | 6: `game-read`, `chat`, `hud`, `storage`, `registries`, `players` |
| Manifest `api` lines served | 3: `0.1` (code), `0.2` (content), `0.3` (server mods, experimental) |
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers; `server::ServerMod` (8 methods), `server::Registration` (5 builders), `export_server_mod!` |
| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers; `server::ServerMod` (8 methods), `server::Registration` (6 builders), `server::Argument`, `server::Args`, `export_server_mod!` |
| `cargo horizon-mod` | `new` (templates `code` and `content`), `build`, `run`, `test`, `check` |
| Example mods | `coords-hud` (code), `ruby-content` (content), `welcome` (server) |
@@ -88,9 +88,9 @@ release.
What real mods need next, in the order modpacks and servers need it:
- **More for server mods** (`api = "0.3"`, [Server mods](../mods.md#server-mods-api-03-experimental)): they run
on the server thread, a LAN host and the dedicated server today, with joins, chat, commands, players' block
breaking and placing, and `world` (read blocks and biomes, set blocks as `/setblock` does); next entity events, game rules,
server commands with typed arguments sent to clients' command trees.
on the server thread, a LAN host and the dedicated server today, with joins, chat, typed commands in players'
command trees, players' block breaking and placing, and `world` (read blocks and biomes, set blocks as `/setblock`
does); next entity events, game rules, entity, item and block-state arguments.
- **World and blocks**: blocks, block states and biomes are there for server mods (`world`); next block entities,
and `world` for client mods (raycasts, single player and LAN hosts).
- **Items and inventories**: the player's inventory, held item and open container; item stacks with components.
@@ -106,6 +106,9 @@ What real mods need next, in the order modpacks and servers need it:
## Changelog
- API 0.3: typed server commands: `registration.commands` lists each command's arguments (`argument-kind`: word,
text, integer, number, boolean, player, block-pos), sent in players' command trees and parsed by the server;
`command` gets their values. SDK `Registration::command_with`, `Argument`, `Args`.
- API 0.3: capability `world` (`get-block`, `get-biome`, `set-blocks`); example `beacons`; test scripts' `blocks` and
`expect.blocks`.
- Server mods run in the game: single player (server thread and in-frame), LAN hosts, the dedicated server.
+1 -1
View File
@@ -108,7 +108,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
| Mod API: `render` (HUD shapes, camera shake and zoom; drawn by the game, no mod shaders) | experimental | manifest `experimental = ["render"]` |
| Mod API: `input` (claimed items' use, the mod's keys, the mouse wheel) | experimental | manifest `experimental = ["input"]` |
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
| Server mods: world `horizon-server-mod`, `players`, `server-events` | experimental | manifest `api = "0.3"` |
| Server mods: world `horizon-server-mod`, `players`, `server-events`, `world`, typed `server-command`s | experimental | manifest `api = "0.3"` |
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
| Shader API | not started | |
+1 -1
View File
@@ -47,7 +47,7 @@ file selects ~103.
| 29 | Title, worlds, startup (49) | `L/title.rs` + `L/title/`, `G/startup_menu.rs`, `L/title_edition.rs` | world_options; `cargo test -p horizon-client --lib title` |
| 30 | Dedicated server, horizon-server (48) | `G/dedicated.rs`, `G/dedicated/status.rs`, `crates/horizon-server/`, `crates/horizon-server-files/` | dedicated_server, server_files, user_cache, online_mode, server_ops_feedback |
| 31 | Particles and client effects (46) | `L/particles.rs`, `G/render_tick.rs`, `G/level_particles.rs`, `L/animate_tick.rs` | particle_providers, block_particles, entity_particles, emitter_particles, marker_particles |
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, end_podium, end_gateway |
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, forced_end, end_podium, end_gateway |
| 33 | Protocol and packets (44) | `crates/horizon-protocol/src/` (`packets/play.rs`, `entity_data.rs`, `connection.rs`) | dedicated_server, online_mode, secure_chat; `cargo test -p horizon-protocol` |
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs`, `L/friends_screen.rs`, `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
+98
View File
@@ -54,6 +54,7 @@ Start the client.
- `--world`: Open or create this world (a folder under saves) at once
- `--creative`: Creative mode for a new world
- `--join`: Join a friend's world with its invite code
- `--server`: Join this server at once: an address, or a server of the list by name or position
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --server-thread
MCP: `play`, then the `game_*` tools.
@@ -330,6 +331,103 @@ Keep an instance on one installed Horizon version, or "latest" to follow updates
MCP: `instance_version`.
## `horizon-launcher worlds list`
The worlds: name, mode, version, last played and size (the default).
horizon-launcher worlds list
- `--instance`: The instance (default: the selected one)
MCP: `worlds_list`.
## `horizon-launcher worlds backup`
Zip a world into the instance's backups/ folder, as the game's Make Backup does.
horizon-launcher worlds backup <WORLD>
- `WORLD`: Its folder or name
- `--instance`
MCP: `worlds_backup`.
## `horizon-launcher worlds copy`
Copy a world beside it, as "<name> (1)".
horizon-launcher worlds copy <WORLD>
- `WORLD`
- `--instance`
MCP: `worlds_copy`.
## `horizon-launcher worlds delete`
Move a world to the system's trash.
horizon-launcher worlds delete <WORLD>
- `WORLD`
- `--instance`
MCP: `worlds_delete`, needs `confirm: true`.
## `horizon-launcher screenshots list`
The screenshots, the newest first, with their files (the default).
horizon-launcher screenshots list
- `--instance`: The instance (default: the selected one)
MCP: `screenshots_list`.
## `horizon-launcher screenshots delete`
Move a screenshot to the system's trash.
horizon-launcher screenshots delete <SCREENSHOT>
- `SCREENSHOT`: Its file name, with or without .png
- `--instance`
MCP: `screenshots_delete`, needs `confirm: true`.
## `horizon-launcher multiplayer list`
The servers of the game's Multiplayer screen (the default).
horizon-launcher multiplayer list
- `--instance`: The instance (default: the selected one)
MCP: `multiplayer_list`.
## `horizon-launcher multiplayer add`
Add a server to the list.
horizon-launcher multiplayer add <ADDRESS>
- `ADDRESS`: Its address, as in the game's Add Server (host or host:port)
- `--name`: Its name in the list (default: Minecraft Server)
- `--instance`
MCP: `multiplayer_add`.
## `horizon-launcher multiplayer remove`
Remove a server from the list: its position from 1, name or address.
horizon-launcher multiplayer remove <SERVER>
- `SERVER`
- `--instance`
MCP: `multiplayer_remove`, needs `confirm: true`.
## `horizon-launcher modpack list`
The imported packs and their coverage (the default).
+31 -5
View File
@@ -488,7 +488,7 @@ its component targets the WIT world `horizon-server-mod` instead of
| `tick(tick)` | each server tick |
| `player-joined(player)`, `player-left(player)` | `registration.joins` |
| `chat(player, text)` | `registration.chat`: each chat message a player sends |
| `command(player, name, args)` | `/name args` typed by a player, for a command it registered; `ok(some)` is that player's feedback, `err` their red error |
| `command(player, name, args, values)` | `/name args` typed by a player, for a command it registered, with its arguments' values (below); `ok(some)` is that player's feedback, `err` their red error |
| `block-broken(player, pos, state)`, `block-placed(player, pos, state)` | `registration.blocks`, with the state as `minecraft:oak_log[axis=y]` |
Capabilities that act on a local player (`game-read`, `chat`, `hud`) are for
@@ -497,6 +497,31 @@ horizon_mod_sdk::server::ServerMod` and `export_server_mod!`; the example is
[`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs), and with
`world`, [`beacons`](../crates/horizon-mod-sdk/examples/beacons/src/lib.rs).
**Typed commands.** Each command in `registration.commands` lists its
arguments, in order: a name and a kind, required or optional (optional ones
last). The server adds them to the command tree every player receives, after
vanilla's commands, so a player's chat box completes and colours them as it
does vanilla's (player names, coordinates, bounds); when a player sends one,
the server parses the line the way vanilla's parsers do and gives the mod the
values, by argument name, or answers with vanilla's error (`Integer must not
be more than 64, found 65`, `No player was found`) without calling the mod.
| Kind | Takes | Value | Chat box parser |
|---|---|---|---|
| `word` | one word: letters, digits, `_ - . +` | `word(string)` | `brigadier:string` (word) |
| `text` | the rest of the line, only last | `text(string)` | `brigadier:string` (greedy) |
| `integer(min, max)` | a whole number within the bounds given | `integer(s32)` | `brigadier:integer` |
| `number` | a number | `number(f64)` | `brigadier:double` |
| `boolean` | `true` or `false` | `boolean(bool)` | `brigadier:bool` |
| `player` | an online player's name, `@s` or `@p` (whoever typed it) | `player(player)` | `minecraft:entity` (one player) |
| `block-pos` | `x y z`, each a whole number, `~` or `~n` (from whoever typed it) | `block-pos(block-pos)` | `minecraft:block_pos` |
A command with no arguments takes nothing after its name. In Rust,
`Registration::command_with("beacon", [Argument::integer("height", Some(1),
Some(64)).optional()])`, read with `args.integer("height")`; `command(name)`
alone takes free text, an optional `text` argument named `args`
(`args.text()` is the line as typed either way).
**Capability `world`.** Reads are of the server's loaded chunks as they are
during the call: the game lends a server mod's call the levels' chunk maps
(moved, not copied, and only while a running mod has `world`), so a read
@@ -537,10 +562,11 @@ chat for the in-frame player and a system message for a connection;
on, each of these points costs one branch (measured in the PR that added
them).
**Not yet.** Clients aren't sent server mods' commands with vanilla's
command tree, so a player's chat box doesn't complete or colour them; typing
them works. Block events are a player's own breaking and placing, not
explosions, pistons or other mods.
**Not yet.** Block events are a player's own breaking and placing, not
explosions, pistons or other mods. Command arguments don't take entities,
items or block states yet, and `^` coordinates; in-frame single player's chat
box lists server mods' commands by name only (the server still parses their
arguments).
## Publish your mod
+220 -6
View File
@@ -70,6 +70,15 @@ dependencies = [
"equator",
]
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -919,6 +928,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -981,6 +1001,15 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "cocoa"
version = "0.25.0"
@@ -1635,6 +1664,18 @@ dependencies = [
"winreg",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -2354,7 +2395,7 @@ dependencies = [
"wayland-protocols-plasma",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-numerics",
"windows-numerics 0.2.0",
"windows-registry 0.5.3",
"x11-clipboard",
"x11rb",
@@ -2693,6 +2734,8 @@ dependencies = [
name = "horizon-launcher"
version = "0.2.0"
dependencies = [
"base64",
"chrono",
"clap",
"dirs 5.0.1",
"flate2",
@@ -2708,6 +2751,7 @@ dependencies = [
"horizon-release-sign",
"horizon-telemetry",
"horizon-theme",
"horizon-world",
"image",
"keyring",
"objc2-app-kit",
@@ -2720,6 +2764,7 @@ dependencies = [
"sha1",
"sha2 0.10.9",
"tar",
"trash",
"ureq",
"winresource",
"zip",
@@ -2801,6 +2846,21 @@ dependencies = [
name = "horizon-theme"
version = "0.1.0"
[[package]]
name = "horizon-world"
version = "0.1.0"
dependencies = [
"flate2",
"horizon-core",
"libm",
"lz4_flex",
"postcard",
"serde",
"serde_json",
"sha2 0.10.9",
"xxhash-rust",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -2907,6 +2967,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -3425,6 +3509,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "lz4_flex"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
[[package]]
name = "mac"
version = "0.1.1"
@@ -4263,6 +4353,18 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
@@ -6187,6 +6289,24 @@ dependencies = [
"once_cell",
]
[[package]]
name = "trash"
version = "5.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317"
dependencies = [
"chrono",
"libc",
"log",
"objc2",
"objc2-foundation",
"once_cell",
"percent-encoding",
"scopeguard",
"urlencoding",
"windows 0.62.2",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -6367,6 +6487,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "usvg"
version = "0.45.1"
@@ -6820,11 +6946,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@@ -6837,7 +6975,7 @@ dependencies = [
"rayon",
"thiserror 2.0.21",
"windows 0.61.3",
"windows-future",
"windows-future 0.2.1",
]
[[package]]
@@ -6849,6 +6987,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.57.0"
@@ -6874,6 +7021,19 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.2.1"
@@ -6882,7 +7042,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -6951,6 +7122,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.4.0"
@@ -6991,6 +7172,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
@@ -7009,6 +7199,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -7111,6 +7310,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -7423,6 +7631,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xxhash-rust"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "y4m"
version = "0.8.0"
+7
View File
@@ -37,6 +37,13 @@ horizon-mods = { path = "../crates/horizon-mods", features = ["index"] }
horizon-release-sign = { path = "../crates/horizon-release-sign" }
# Opt-in data collection: the consent, the outbox and its upload (docs/telemetry.md).
horizon-telemetry = { path = "../crates/horizon-telemetry" }
# level.dat and servers.dat (the worlds and server lists of instances)
horizon-world = { path = "../crates/horizon-world" }
# Deleting a world moves it to the system's trash
trash = "5"
# Backup names carry the local time, as vanilla's do
base64 = "0.22"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# The session file's random name.
getrandom = "0.3"
# Players' heads on the Servers page, from the default skins in the player's own game files (already
+28 -3
View File
@@ -78,9 +78,28 @@ The opening, then Play ([clip](docs/opening.webm)):
data folder, so renaming moves nothing. Every instance follows the launcher's data collection
answer and No AI. In the window, **+** beside Instances makes one, the sidebar lists them by
group (click one to play it), and Home's Instance row has Edit (name, group, icon), Copy and
Open folder. Each instance plays the launcher's version, which follows updates, unless the Play
page's version picker keeps it on another installed one (to keep an old world on the version
it was made with); picking the version marked "follows updates" lets it follow again.
Open folder. The Instance row shows its play time and when it was last played: every run started
from the window or `horizon-launcher play` adds to it (`instance list` shows it too). Each
instance plays the launcher's version, which follows updates, unless the Play page's version
picker keeps it on another installed one (to keep an old world on the version it was made
with); picking the version marked "follows updates" lets it follow again.
- **Worlds**: Home lists the selected instance's worlds (picture, mode, version, last played,
size). **Back up** zips one into the instance's `backups/` as the game's Make Backup does
(`<date>_<time>_<folder>.zip`); **Copy** puts "<name> (1)" beside it; **Delete** moves it to the
system's trash after a confirmation; **Open folder**. The same from the command line:
`horizon-launcher worlds [list|backup|copy|delete] [--instance <name>]`.
**Play** on a world starts the game straight into it.
- **Screenshots**: Home shows the selected instance's newest screenshots (what F2 saves in its
`screenshots/` folder) and all of them on request. Clicking one opens it over the window (← and →
step through) with **Copy** (the picture, to the clipboard), **Open**, **Show in folder** and
**Delete** (to the trash, after a confirmation). From the command line:
`horizon-launcher screenshots [list|delete] [--instance <name>]`.
- **Multiplayer**: Home lists the selected instance's servers, the game's own Multiplayer list
(`servers.dat`, each with the picture the game last saw). **Join** starts the game straight into
one; **Add server** and **Remove** (after a confirmation) change the file the game reads,
keeping every field the launcher doesn't show and the servers joined with Direct Connection. The
same from the command line: `horizon-launcher multiplayer [list|add|remove] [--instance <name>]`
and `horizon-launcher play --server <address or name>`.
- **Patch notes**: each release's notes, newest first, with a link to GitHub Sponsors. Quiet "Discord" and "Website" links sit in the footer.
Data lives in the platform's data directory (`~/.local/share/horizon-launcher` on Linux,
@@ -181,6 +200,12 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
horizon-launcher instance rename|icon|group <instance> <value> icon: a block or item name, #rrggbb or none
horizon-launcher instance select <instance> make Play start it
horizon-launcher instance version <instance> <tag|latest> keep it on one installed version, or follow updates
horizon-launcher worlds [list] [--instance <name>] an instance's worlds
horizon-launcher worlds backup|copy|delete <world> [--instance <name>] delete moves it to the trash
horizon-launcher screenshots [list] [--instance <name>] an instance's screenshots, the newest first
horizon-launcher screenshots delete <name> [--instance <name>] moves it to the trash
horizon-launcher multiplayer [list] [--instance <name>] the servers in an instance's Multiplayer list
horizon-launcher multiplayer add <address> [--name <name>] | remove <server>
horizon-launcher modpack [list] the imported packs and their coverage
horizon-launcher modpack import <pack> a .mrpack, CurseForge zip or instance folder
horizon-launcher modpack port <pack> [--from <dir>] [--version <tag>] get the jars and carry them over
+276 -3
View File
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
use horizon_launcher::account::{self, Accounts};
use horizon_launcher::backend::{self, Release};
use horizon_launcher::instances;
use horizon_launcher::server_list;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::process::Command;
@@ -184,6 +185,78 @@ enum InstanceCmd {
Version { instance: String, version: String },
}
#[derive(Subcommand)]
enum WorldsCmd {
/// The worlds: name, mode, version, last played and size (the default)
List {
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Zip a world into the instance's backups/ folder, as the game's Make Backup does
Backup {
/// Its folder or name
world: String,
#[arg(long)]
instance: Option<String>,
},
/// Copy a world beside it, as "<name> (1)"
Copy {
world: String,
#[arg(long)]
instance: Option<String>,
},
/// Move a world to the system's trash
Delete {
world: String,
#[arg(long)]
instance: Option<String>,
},
}
#[derive(Subcommand)]
enum ScreenshotsCmd {
/// The screenshots, the newest first, with their files (the default)
List {
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Move a screenshot to the system's trash
Delete {
/// Its file name, with or without .png
screenshot: String,
#[arg(long)]
instance: Option<String>,
},
}
#[derive(Subcommand)]
enum MultiplayerCmd {
/// The servers of the game's Multiplayer screen (the default)
List {
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Add a server to the list
Add {
/// Its address, as in the game's Add Server (host or host:port)
address: String,
/// Its name in the list (default: Minecraft Server)
#[arg(long)]
name: Option<String>,
#[arg(long)]
instance: Option<String>,
},
/// Remove a server from the list: its position from 1, name or address
Remove {
server: String,
#[arg(long)]
instance: Option<String>,
},
}
#[derive(Subcommand)]
enum ModsCmd {
/// What the browser installed (the default)
@@ -302,6 +375,9 @@ enum Cmd {
/// Join a friend's world with its invite code
#[arg(long)]
join: Option<String>,
/// Join this server at once: an address, or a server of the list by name or position
#[arg(long, conflicts_with_all = ["world", "join"])]
server: Option<String>,
/// An extra argument for the game, passed through as-is (repeatable), for example
/// --server-thread
#[arg(long = "arg", value_name = "ARG", allow_hyphen_values = true)]
@@ -378,6 +454,21 @@ enum Cmd {
#[command(subcommand)]
action: Option<InstanceCmd>,
},
/// An instance's single-player worlds: list them, back one up, copy or delete it
Worlds {
#[command(subcommand)]
action: Option<WorldsCmd>,
},
/// The screenshots F2 took in an instance: list, delete
Screenshots {
#[command(subcommand)]
action: Option<ScreenshotsCmd>,
},
/// The servers in an instance's Multiplayer list (servers.dat): list, add, remove
Multiplayer {
#[command(subcommand)]
action: Option<MultiplayerCmd>,
},
/// Modpacks: import a Java pack, see its coverage report, port what this computer can and
/// play it as its own profile (docs/modpacks.md)
Modpack {
@@ -647,6 +738,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
world,
creative,
join,
server,
arg,
} => {
let instance = match instance {
@@ -661,11 +753,21 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
if let Some(world) = world {
cmd.arg("--world").arg(world);
}
if let Some(server) = server {
let list = server_list::List::load(&instance.home);
let ip = server_list::find(&list, &server).map_or(server, |(_, s)| s.ip);
cmd.arg("--server").arg(ip);
}
if creative {
cmd.arg("--creative");
}
cmd.args(arg);
attached(cmd)?
let started = std::time::Instant::now();
let played = attached(cmd);
if let Err(e) = instances::record_play(&instance.id, started.elapsed()) {
eprintln!("Play time not saved: {e}");
}
played?
}
Cmd::Server {
dir,
@@ -827,6 +929,18 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
println!("Horizon is in the applications menu.");
}
}
Cmd::Worlds { action } => worlds(
action.unwrap_or(WorldsCmd::List { instance: None }),
as_json,
)?,
Cmd::Screenshots { action } => screenshots(
action.unwrap_or(ScreenshotsCmd::List { instance: None }),
as_json,
)?,
Cmd::Multiplayer { action } => multiplayer(
action.unwrap_or(MultiplayerCmd::List { instance: None }),
as_json,
)?,
Cmd::Instance { action } => instance(action.unwrap_or(InstanceCmd::List), as_json)?,
Cmd::Modpack { action } => modpack(action.unwrap_or(ModpackCmd::List), as_json)?,
Cmd::Mods { action } => mods(action.unwrap_or(ModsCmd::List), as_json)?,
@@ -1387,7 +1501,8 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
serde_json::json!({
"id": i.id, "name": i.name, "kind": format!("{:?}", i.kind).to_lowercase(),
"home": i.home, "icon": i.icon, "group": i.group, "selected": selected,
"version": i.version,
"version": i.version, "played_secs": i.played.as_secs(),
"last_played_ms": i.last_played.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64),
})
};
let line = |i: &instances::Instance, selected: bool| {
@@ -1402,8 +1517,12 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
.as_ref()
.map(|v| format!(" {v}"))
.unwrap_or_default();
let played = match i.last_played {
Some(_) => format!(" {} played", instances::play_time(i.played)),
None => String::new(),
};
println!(
"{mark} {} ({}){pinned}{group}\n {}",
"{mark} {} ({}){pinned}{group}{played}\n {}",
i.name,
i.id,
i.home.display()
@@ -1449,6 +1568,160 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
Ok(())
}
fn screenshots(action: ScreenshotsCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::screenshots;
let home = |instance: Option<String>| -> Result<PathBuf, String> {
Ok(match instance {
Some(name) => instances::find(&name)?.home,
None => instances::selected().home,
})
};
match action {
ScreenshotsCmd::List { instance } => {
let home = home(instance)?;
let shots = screenshots::list(&home);
if as_json {
json(
shots
.iter()
.map(|s| {
let millis = s
.taken
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
serde_json::json!({ "name": s.name, "path": s.path, "taken_ms": millis, "size": s.size })
})
.collect(),
);
} else if shots.is_empty() {
println!("No screenshots in {}", screenshots::folder(&home).display());
}
for s in shots.iter().filter(|_| !as_json) {
println!("{}", s.path.display());
}
}
ScreenshotsCmd::Delete {
screenshot,
instance,
} => {
let shot = screenshots::find(&home(instance)?, &screenshot)?;
screenshots::delete(&shot)?;
println!("{} is in the trash.", shot.name);
}
}
Ok(())
}
fn multiplayer(action: MultiplayerCmd, as_json: bool) -> Result<(), String> {
let home = |instance: Option<String>| -> Result<PathBuf, String> {
Ok(match instance {
Some(name) => instances::find(&name)?.home,
None => instances::selected().home,
})
};
match action {
MultiplayerCmd::List { instance } => {
let home = home(instance)?;
let servers = server_list::List::load(&home).servers();
if as_json {
json(
servers
.iter()
.map(|s| serde_json::json!({ "name": s.name, "address": s.ip, "icon": s.icon.is_some() }))
.collect(),
);
} else if servers.is_empty() {
println!("No servers in {}", home.join("servers.dat").display());
}
for (i, s) in servers.iter().enumerate().filter(|_| !as_json) {
println!("{}. {} ({})", i + 1, s.name, s.ip);
}
}
MultiplayerCmd::Add {
address,
name,
instance,
} => {
let home = home(instance)?;
let mut list = server_list::List::load(&home);
list.add(name.as_deref().unwrap_or(""), &address)?;
list.save(&home)?;
println!("Added {}.", address.trim());
}
MultiplayerCmd::Remove { server, instance } => {
let home = home(instance)?;
let mut list = server_list::List::load(&home);
let (i, _) = server_list::find(&list, &server)?;
let gone = list.remove(i)?;
list.save(&home)?;
println!("Removed {} ({}).", gone.name, gone.ip);
}
}
Ok(())
}
fn worlds(action: WorldsCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::worlds::{self, World};
let home = |instance: Option<String>| -> Result<PathBuf, String> {
Ok(match instance {
Some(name) => instances::find(&name)?.home,
None => instances::selected().home,
})
};
let value = |w: &World| {
let millis = w
.last_played
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
serde_json::json!({
"folder": w.folder, "name": w.name, "mode": w.mode, "version": w.version,
"last_played_ms": millis, "size": w.size, "path": w.path, "icon": w.icon,
})
};
match action {
WorldsCmd::List { instance } => {
let home = home(instance)?;
let all = worlds::list(&home);
if as_json {
json(all.iter().map(value).collect());
} else if all.is_empty() {
println!("No worlds in {}", home.join("saves").display());
}
for w in all.iter().filter(|_| !as_json) {
let version = w.version.as_deref().unwrap_or("unknown version");
let mb = w.size as f64 / 1_000_000.0;
println!(
"{} ({})\n {}, {version}, {mb:.1} MB",
w.name, w.folder, w.mode
);
}
}
WorldsCmd::Backup { world, instance } => {
let home = home(instance)?;
let zip = worlds::backup(&home, &worlds::find(&home, &world)?)?;
match as_json {
true => json(serde_json::json!({ "backup": zip })),
false => println!("{}", zip.display()),
}
}
WorldsCmd::Copy { world, instance } => {
let home = home(instance)?;
let copy = worlds::copy(&worlds::find(&home, &world)?)?;
match as_json {
true => json(value(&copy)),
false => println!("Copied to {} ({})", copy.name, copy.folder),
}
}
WorldsCmd::Delete { world, instance } => {
let home = home(instance)?;
let w = worlds::find(&home, &world)?;
worlds::delete(&w)?;
println!("{} is in the trash.", w.name);
}
}
Ok(())
}
fn modpack(action: ModpackCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::browser::horizon;
use horizon_launcher::modpacks::{self, Imported};
+3
View File
@@ -26,6 +26,9 @@ pub const DESTRUCTIVE: &[&str] = &[
"servers forget",
"account sign-out",
"data delete",
"worlds delete",
"multiplayer remove",
"screenshots delete",
];
/// Commands that send something or reach other people: only when the player asks.
+47
View File
@@ -14,6 +14,7 @@ use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// The default instance's id: the game's home itself.
pub const DEFAULT: &str = "horizon";
@@ -90,6 +91,16 @@ pub struct Meta {
/// launcher's own version, which follows updates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// Seconds played in it, every run started by the launcher (the window or `play`) added up.
#[serde(default, skip_serializing_if = "is_zero")]
pub played_secs: u64,
/// When its last run ended, in seconds since 1970.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_played: Option<u64>,
}
fn is_zero(n: &u64) -> bool {
*n == 0
}
/// `instances.json`: each instance's choices, by id.
@@ -127,6 +138,8 @@ pub struct Instance {
pub group: Option<String>,
/// Its pinned Horizon version, if any (`Meta::version`).
pub version: Option<String>,
pub played: Duration,
pub last_played: Option<SystemTime>,
}
impl Instance {
@@ -196,6 +209,10 @@ fn list_in(game_home: &Path, data: &Path) -> Vec<Instance> {
icon: meta.icon,
group: meta.group,
version: meta.version,
played: Duration::from_secs(meta.played_secs),
last_played: meta
.last_played
.map(|s| UNIX_EPOCH + Duration::from_secs(s)),
id,
kind,
work,
@@ -397,6 +414,36 @@ fn edit_in(
find_in(&list_in(game_home, data), &instance.id)
}
/// Adds a finished run of the game to the instance's play time (by id: a renamed instance keeps
/// it).
pub fn record_play(id: &str, played: Duration) -> Result<(), String> {
record_play_in(&backend::data_dir(), id, played, SystemTime::now())
}
fn record_play_in(
data: &Path,
id: &str,
played: Duration,
ended: SystemTime,
) -> Result<(), String> {
let mut store = Store::load(data);
let meta = store.instances.entry(id.to_string()).or_default();
meta.played_secs = meta.played_secs.saturating_add(played.as_secs());
meta.last_played = ended.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs());
store.save(data)
}
/// "12 min", "3 h 05 min", "140 h": the play time as players read it.
pub fn play_time(played: Duration) -> String {
let minutes = played.as_secs() / 60;
match (minutes / 60, minutes % 60) {
(0, 0) => "under a minute".into(),
(0, m) => format!("{m} min"),
(h, m) if h < 100 => format!("{h} h {m:02} min"),
(h, _) => format!("{h} h"),
}
}
/// Turns the game's command into this instance: its own game folder, and a pack's mods. The
/// player's answers that hold everywhere follow it in: data collection and No AI as the
/// launcher has them, and options.txt and the offline name when it has none yet.
+34
View File
@@ -224,3 +224,37 @@ fn an_instance_keeps_its_own_version_until_it_follows_updates_again() {
assert_eq!(follows.version, None);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn play_time_adds_up_per_instance_and_survives_a_rename() {
let root = scratch("play-time");
let (home, data) = (root.join("home"), root.join("data"));
fs::create_dir_all(home.join(OWN).join("skyblock")).unwrap();
let ended = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
record_play_in(&data, "skyblock", Duration::from_secs(25 * 60 + 10), ended).unwrap();
edit_in(&home, &data, "skyblock", |m| m.name = Some("Sky".into())).unwrap();
let later = ended + Duration::from_secs(3600);
record_play_in(&data, "skyblock", Duration::from_secs(40 * 60), later).unwrap();
let all = list_in(&home, &data);
let sky = find_in(&all, "Sky").unwrap();
assert_eq!(sky.played, Duration::from_secs(65 * 60 + 10));
assert_eq!(sky.last_played, Some(later));
let default = find_in(&all, DEFAULT).unwrap();
assert_eq!(
(default.played, default.last_played),
(Duration::ZERO, None)
);
// Never played: nothing written for it.
let text = fs::read_to_string(data.join(FILE)).unwrap();
assert!(!text.contains("horizon"), "{text}");
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn play_time_reads_as_minutes_then_hours() {
let t = |s| play_time(Duration::from_secs(s));
assert_eq!(t(59), "under a minute");
assert_eq!(t(12 * 60 + 59), "12 min");
assert_eq!(t(3 * 3600 + 5 * 60), "3 h 05 min");
assert_eq!(t(140 * 3600 + 7 * 60), "140 h");
}
+25 -1
View File
@@ -584,13 +584,37 @@ impl Launcher {
Some(index_row(
n,
"Instance",
Some(div().child(instance.name.clone()).into_any_element()),
Some(
div()
.flex()
.items_baseline()
.gap(px(10.))
.child(instance.name.clone())
.child(
text(theme::CAPTION)
.text_color(t.muted)
.child(played_line(&instance)),
)
.into_any_element(),
),
Some(actions.into_any_element()),
&t,
))
}
}
/// "3 h 05 min in total · last played today", or "Not played yet".
fn played_line(instance: &Instance) -> String {
match instance.last_played {
Some(when) => format!(
"{} in total · last played {}",
instances::play_time(instance.played),
worlds_page::ago(when)
),
None => "Not played yet".into(),
}
}
/// The first letter or digit of a name, for an icon without a picture.
fn initial(name: &str) -> String {
name.chars()
+3
View File
@@ -12,8 +12,11 @@ pub mod modpacks;
pub mod mojang;
pub mod process;
pub mod proxy;
pub mod screenshots;
pub mod server_list;
pub mod servers;
pub mod signed;
pub mod sources;
pub mod tailscale;
pub mod telemetry;
pub mod worlds;
+72 -4
View File
@@ -19,12 +19,15 @@ mod mcp;
mod modpacks_page;
mod mods_page;
mod mods_project;
mod multiplayer_page;
mod partner;
mod perf;
mod screenshots_page;
mod scrollbar;
mod servers_page;
mod theme;
mod titlebar;
mod worlds_page;
use gpui::{
actions, div, ease_out_quint, prelude::*, px, relative, size, Animation, AnimationExt,
@@ -185,6 +188,10 @@ struct Launcher {
friend_focused: bool,
friend_focus: FocusHandle,
joining: Option<String>,
/// Where the next start goes at once: `--world <folder>` or `--server <address>`.
quick_play: Option<(&'static str, String)>,
/// The instance the running game plays and when it started: its play time grows on exit.
client_run: Option<(String, Instant)>,
/// Settings' Game arguments field: the line, whether it has focus, and its focus handle.
args_input: String,
args_focused: bool,
@@ -226,6 +233,10 @@ struct Launcher {
mods: mods_page::Mods,
/// The instances, for the sidebar and Play, and their dialog (`instances_page.rs`).
instances: instances_page::Instances,
/// Home's Worlds section, of the selected instance (`worlds_page.rs`).
worlds: worlds_page::Worlds,
multiplayer: multiplayer_page::Multiplayer,
screenshots: screenshots_page::Screenshots,
/// The name the game made up for an offline player (`horizon-player.txt`), for the top bar.
offline_name: Option<String>,
/// The player's servers, for the sidebar until the Servers page has read them.
@@ -361,6 +372,8 @@ impl Launcher {
friend_focused: false,
friend_focus: cx.focus_handle(),
joining: None,
quick_play: None,
client_run: None,
collection: collection::Collection::load(),
agents: agents_page::AgentsUi::default(),
playing: None,
@@ -383,6 +396,9 @@ impl Launcher {
starting_pack: None,
mods: mods_page::Mods::new(cx),
instances: instances_page::Instances::default(),
worlds: worlds_page::Worlds::default(),
multiplayer: multiplayer_page::Multiplayer::default(),
screenshots: screenshots_page::Screenshots::default(),
offline_name: offline_name(),
side_servers: horizon_launcher::servers::list(),
art: art::Art::default(),
@@ -542,11 +558,20 @@ impl Launcher {
}
self.accounts = Accounts::load();
// A pack's Play button plays that pack; Play plays the selected instance.
let profile = match self.starting_pack.take() {
let (profile, run) = match self.starting_pack.take() {
Some(work) => {
horizon_launcher::modpacks::apply_profile(&mut cmd, &work)
let id = instances::list()
.into_iter()
.find(|i| i.work == work)
.map(|i| i.id);
let applied =
horizon_launcher::modpacks::apply_profile(&mut cmd, &work);
(applied, id)
}
None => {
let instance = instances::selected();
(instances::apply(&mut cmd, &instance), Some(instance.id))
}
None => instances::apply(&mut cmd, &instances::selected()),
};
if let Err(e) = profile {
self.client_error = Some(e);
@@ -557,10 +582,16 @@ impl Launcher {
self.client_error = Some(e);
}
}
if let Some((flag, value)) = self.quick_play.take() {
cmd.arg(flag).arg(value);
}
backend::append_game_args(&mut cmd);
self.client_home = cmd.get_current_dir().map(|d| d.to_path_buf());
match backend::spawn(Job::Client, cmd, self.tx.clone()) {
Ok(r) => self.client = Some(r),
Ok(r) => {
self.client = Some(r);
self.client_run = run.map(|id| (id, Instant::now()));
}
Err(e) => self.client_error = Some(e),
}
}
@@ -677,6 +708,16 @@ impl Launcher {
}
Job::Client => {
self.client = None;
// The game saved: last played, sizes and pictures changed.
self.worlds.stale();
self.multiplayer.stale();
self.screenshots.stale();
if let Some((id, started)) = self.client_run.take() {
if let Err(e) = instances::record_play(&id, started.elapsed()) {
eprintln!("Play time not saved: {e}");
}
self.reload_instances();
}
// The run's own game folder: an instance's or a pack's keeps its reports.
let home = self.client_home.clone().unwrap_or_else(backend::game_home);
let report = self
@@ -2157,6 +2198,15 @@ impl Launcher {
page = page.child(notice(e, &t));
}
page = page.child(rows);
if let Some(worlds) = self.worlds_section(cx) {
page = page.child(worlds);
}
if let Some(servers) = self.multiplayer_section(cx) {
page = page.child(servers);
}
if let Some(shots) = self.screenshots_section(cx) {
page = page.child(shots);
}
if let Some(e) = self.client_error.clone() {
page = page.child(notice(e, &t));
}
@@ -3054,6 +3104,16 @@ impl Launcher {
)
}
/// Plays the selected instance straight into a world (`--world`) or a server (`--server`):
/// flags every Horizon release takes.
pub(crate) fn quick_play(&mut self, flag: &'static str, value: String) {
self.quick_play = Some((flag, value));
self.play();
if self.starting_client.is_none() {
self.quick_play = None;
}
}
fn join_friend(&mut self) {
if horizon_friends::invite::parse(&self.friend_code).is_none() {
return;
@@ -3494,6 +3554,9 @@ impl Render for Launcher {
}
"escape" if this.servers_escape() => {}
"escape" if this.instance_escape() => {}
"escape" if this.screenshot_escape() => {}
"left" if this.screenshot_step(false) => {}
"right" if this.screenshot_step(true) => {}
_ => return,
}
cx.stop_propagation();
@@ -3557,6 +3620,11 @@ impl Render for Launcher {
if let Some(dialog) = self.instance_dialog(cx) {
root = root.child(dialog);
}
if self.page == Page::Play {
if let Some(viewer) = self.screenshot_viewer(cx) {
root = root.child(viewer);
}
}
if self.asking_consent() {
root = root.child(self.consent_screen(cx));
}
+7
View File
@@ -34,6 +34,7 @@ fn session_tools() -> Value {
"world": { "type": "string", "description": "Open or create this world (a folder under saves) at once." },
"creative": { "type": "boolean", "description": "Creative mode for a new world." },
"join": { "type": "string", "description": "Join a friend's world with its invite code. Only when the player asks." },
"server": { "type": "string", "description": "Join this server at once: an address, or a server of the instance's list (multiplayer_list) by name or position. Only when the player asks." },
"offscreen": { "type": "boolean", "description": "Render without showing a window (game_screenshot still works)." },
"arg": { "type": "array", "items": { "type": "string" }, "description": "Extra game arguments, passed through as-is (for example \"--server-thread\")." } } } },
{ "name": "server", "description": "Start a Horizon dedicated server. It runs until server_stop or the end of this MCP session. Requires agreeing to the Minecraft EULA. CLI: horizon-launcher server",
@@ -87,6 +88,12 @@ impl Session {
if let Some(code) = arg_str(args, "join") {
backend::join_friend(&mut cmd, code)?;
}
if let Some(server) = arg_str(args, "server") {
let list = horizon_launcher::server_list::List::load(&instance.home);
let ip = horizon_launcher::server_list::find(&list, server)
.map_or_else(|_| server.to_string(), |(_, s)| s.ip);
cmd.arg("--server").arg(ip);
}
if let Some(extra) = args.get("arg").and_then(Value::as_array) {
cmd.args(extra.iter().filter_map(Value::as_str));
}
+251
View File
@@ -0,0 +1,251 @@
//! The selected instance's server list on Home (`horizon_launcher::server_list`, the game's own
//! `servers.dat`): each server with the picture the game last saw, Join (the game starts straight
//! into it), Remove after a confirmation, and a line to add one.
use super::*;
use gpui::{Image, ImageFormat};
use horizon_launcher::server_list::{self, Server};
use servers_page::Field;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Default)]
pub(super) struct Multiplayer {
/// The game folder the list is of, and the list as last read with each picture decoded.
home: Option<PathBuf>,
list: Vec<(Server, Option<Arc<Image>>)>,
fresh: bool,
adding: bool,
confirm_remove: Option<usize>,
error: Option<String>,
}
impl Multiplayer {
/// Read again on the next frame: the game may have changed its list.
pub(super) fn stale(&mut self) {
self.fresh = false;
}
}
impl Launcher {
fn load_servers(&mut self, home: PathBuf) {
if self.multiplayer.home.as_ref() != Some(&home) {
self.multiplayer = Multiplayer {
home: Some(home.clone()),
..Multiplayer::default()
};
}
if self.multiplayer.fresh {
return;
}
// A few servers and their 64×64 pictures: small enough to read while drawing.
self.multiplayer.list = server_list::List::load(&home)
.servers()
.into_iter()
.map(|s| {
let icon = s
.icon
.clone()
.map(|png| Arc::new(Image::from_bytes(ImageFormat::Png, png)));
(s, icon)
})
.collect();
self.multiplayer.fresh = true;
}
fn edit_servers(&mut self, edit: impl FnOnce(&mut server_list::List) -> Result<(), String>) {
let Some(home) = self.multiplayer.home.clone() else {
return;
};
let mut list = server_list::List::load(&home);
self.multiplayer.error = edit(&mut list).and_then(|()| list.save(&home)).err();
self.multiplayer.confirm_remove = None;
self.multiplayer.stale();
}
/// Enter in the address field, or Add.
pub(super) fn add_server(&mut self) {
let name = self.servers.value(Field::ServerName).to_string();
let address = self.servers.value(Field::ServerAddress).to_string();
self.edit_servers(|list| list.add(&name, &address));
if self.multiplayer.error.is_none() {
self.servers.text(Field::ServerName).clear();
self.servers.text(Field::ServerAddress).clear();
self.multiplayer.adding = false;
}
}
/// Home's Multiplayer section, for the selected instance.
pub(super) fn multiplayer_section(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let home = self.selected_instance()?.home.clone();
self.load_servers(home);
let header = div()
.flex()
.items_center()
.justify_between()
.mb(px(4.))
.child(section_title("Multiplayer", "server", &t))
.child(
button("server-add", "Add server", Kind::Ghost, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.multiplayer.adding = !this.multiplayer.adding;
this.multiplayer.error = None;
if this.multiplayer.adding {
this.servers.focus_soon = Some(Field::ServerAddress);
}
cx.notify();
},
)),
);
let mut section = div().flex().flex_col().gap(px(8.)).child(header);
if let Some(e) = self.multiplayer.error.clone() {
section = section.child(notice(e, &t));
}
if self.multiplayer.adding {
section = section.child(
plate(&t)
.flex()
.items_center()
.gap(px(8.))
.px(px(14.))
.py(px(10.))
.child(self.field(Field::ServerName, "Minecraft Server", 220., cx))
.child(self.field(Field::ServerAddress, "play.example.com", 300., cx))
.child(button("server-save", "Add", Kind::Secondary, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.add_server();
cx.notify();
}),
)),
);
}
let list = self.multiplayer.list.clone();
if list.is_empty() && !self.multiplayer.adding {
section = section.child(
text(theme::CAPTION)
.text_color(t.muted)
.child("No servers yet: add one here or in the game's Multiplayer screen."),
);
}
for (i, (server, icon)) in list.into_iter().enumerate() {
section = section.child(self.server_row(i, server, icon, cx));
}
Some(section)
}
fn server_row(
&mut self,
i: usize,
server: Server,
icon: Option<Arc<Image>>,
cx: &mut Context<Self>,
) -> Div {
let t = self.theme;
let picture = div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(48.))
.border_2()
.border_color(t.outline)
.bg(t.well);
let picture = match icon {
Some(image) => picture.child(gpui::img(image).size(px(44.))),
None => picture.child(blocks::icon("server", 22., t.muted)),
};
let mut actions = div().flex().items_center().gap(px(6.));
if self.multiplayer.confirm_remove == Some(i) {
actions = actions
.child(
text(theme::CAPTION)
.text_color(t.danger)
.child("Remove it from the list?"),
)
.child(
button(
SharedString::from(format!("server-keep-{i}")),
"Keep",
Kind::Ghost,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.multiplayer.confirm_remove = None;
cx.notify();
})),
)
.child(
button(
SharedString::from(format!("server-remove-{i}")),
"Remove",
Kind::Secondary,
&t,
)
.on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.edit_servers(|list| list.remove(i).map(|_| ()));
cx.notify();
},
)),
);
} else {
let ip = server.ip.clone();
let busy = self.client.is_some() || self.starting_client.is_some();
let join = button(
SharedString::from(format!("server-join-{i}")),
"Join",
Kind::Secondary,
&t,
);
let join = if busy { disabled(join) } else { join };
actions = actions
.child(
join.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.quick_play("--server", ip.clone());
cx.notify();
})),
)
.child(
button(
SharedString::from(format!("server-delete-{i}")),
"Remove",
Kind::Ghost,
&t,
)
.on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.multiplayer.confirm_remove = Some(i);
cx.notify();
},
)),
);
}
plate(&t)
.flex()
.items_center()
.gap(px(14.))
.px(px(14.))
.py(px(10.))
.child(picture)
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
blocks::pixel(15., FontWeight::MEDIUM)
.text_color(t.ink)
.truncate()
.child(server.name.clone()),
)
.child(
text(theme::CAPTION)
.text_color(t.muted)
.truncate()
.child(server.ip.clone()),
),
)
.child(actions)
}
}
+103
View File
@@ -0,0 +1,103 @@
//! An instance's screenshots: the pictures F2 saves into `screenshots/` in its game folder
//! (vanilla's `Screenshot.grab`, named `<yyyy-MM-dd_HH.mm.ss>.png`).
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq)]
pub struct Shot {
pub path: PathBuf,
/// Its file name, the id commands take.
pub name: String,
/// When the file was written.
pub taken: SystemTime,
pub size: u64,
}
/// The folder F2 saves into for the game folder `home`.
pub fn folder(home: &Path) -> PathBuf {
home.join("screenshots")
}
/// The pictures in `home`'s screenshots folder, the newest first.
pub fn list(home: &Path) -> Vec<Shot> {
let mut shots: Vec<Shot> = fs::read_dir(folder(home))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let path = e.path();
let picture = path
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| ["png", "jpg", "jpeg"].contains(&x.to_ascii_lowercase().as_str()));
let meta = e.metadata().ok().filter(|m| m.is_file() && picture)?;
Some(Shot {
name: e.file_name().to_string_lossy().into_owned(),
taken: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
size: meta.len(),
path,
})
})
.collect();
// Same second: the game names the next ones `_2`, `_3`..., which sort after the first.
shots.sort_by(|a, b| b.taken.cmp(&a.taken).then_with(|| b.name.cmp(&a.name)));
shots
}
/// The screenshot a command names: its file name, with or without the extension.
pub fn find(home: &Path, query: &str) -> Result<Shot, String> {
list(home)
.into_iter()
.find(|s| s.name == query || Path::new(&s.name).file_stem().is_some_and(|n| n == query))
.ok_or_else(|| format!("No screenshot {query} in {}", folder(home).display()))
}
/// Moves the screenshot to the system's trash.
pub fn delete(shot: &Shot) -> Result<(), String> {
crate::worlds::to_trash(&shot.path)
.map_err(|e| format!("Could not move {} to the trash: {e}", shot.name))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn lists_pictures_newest_first() {
let home =
std::env::temp_dir().join(format!("horizon-launcher-shots-{}", std::process::id()));
let _ = fs::remove_dir_all(&home);
let dir = folder(&home);
fs::create_dir_all(dir.join("not-a-picture.png")).unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
for (name, age) in [
("2026-10-25_20.00.01.png", 60),
("2026-10-27_09.15.42.png", 0),
("2026-10-27_09.15.42_2.png", 0),
("notes.txt", 0),
("Edited.JPG", 30),
] {
let path = dir.join(name);
fs::write(&path, b"x").unwrap();
let file = fs::File::options().write(true).open(&path).unwrap();
file.set_modified(base - Duration::from_secs(age)).unwrap();
}
let names: Vec<_> = list(&home).into_iter().map(|s| s.name).collect();
assert_eq!(
names,
[
"2026-10-27_09.15.42_2.png",
"2026-10-27_09.15.42.png",
"Edited.JPG",
"2026-10-25_20.00.01.png"
]
);
assert_eq!(find(&home, "2026-10-25_20.00.01").unwrap().size, 1);
assert_eq!(find(&home, "Edited.JPG").unwrap().name, "Edited.JPG");
assert!(find(&home, "notes.txt").is_err());
assert!(list(&home.join("none")).is_empty());
fs::remove_dir_all(&home).unwrap();
}
}
+322
View File
@@ -0,0 +1,322 @@
//! The selected instance's screenshots on Home (`horizon_launcher::screenshots`): the newest as
//! thumbnails, all of them on request, and a viewer (← and → step through) with Copy, Open,
//! Show in folder and Delete (to the trash, after a confirmation).
use super::*;
use gpui::{ClipboardItem, Image, ImageFormat};
use horizon_launcher::screenshots::{self, Shot};
use std::path::PathBuf;
/// Thumbnails shown before "Show all".
const FIRST: usize = 8;
const THUMB_W: f32 = 196.;
const THUMB_H: f32 = THUMB_W * 9. / 16.;
#[derive(Default)]
pub(super) struct Screenshots {
home: Option<PathBuf>,
list: Vec<Shot>,
fresh: bool,
all: bool,
/// The picture open in the viewer, by its place in `list`.
viewing: Option<usize>,
confirm_delete: bool,
/// What the last action did, or why it failed.
note: Option<Result<String, String>>,
}
impl Screenshots {
/// Read again on the next frame: the game may have taken new ones.
pub(super) fn stale(&mut self) {
self.fresh = false;
}
}
impl Launcher {
fn load_screenshots(&mut self, home: PathBuf) {
if self.screenshots.home.as_ref() != Some(&home) {
self.screenshots = Screenshots {
home: Some(home.clone()),
..Screenshots::default()
};
}
if !self.screenshots.fresh {
// One directory listing: cheap enough while drawing.
self.screenshots.list = screenshots::list(&home);
self.screenshots.fresh = true;
let last = self.screenshots.list.len().checked_sub(1);
self.screenshots.viewing = self
.screenshots
.viewing
.and_then(|i| last.map(|l| i.min(l)));
}
}
/// Escape closes the viewer. Whether it did.
pub(super) fn screenshot_escape(&mut self) -> bool {
let open = self.screenshots.viewing.take().is_some();
self.screenshots.confirm_delete = false;
open
}
/// ← and → in the viewer: the newer and the older picture. Whether it moved.
pub(super) fn screenshot_step(&mut self, older: bool) -> bool {
let Some(i) = self.screenshots.viewing else {
return false;
};
let next = match older {
true => (i + 1 < self.screenshots.list.len()).then_some(i + 1),
false => i.checked_sub(1),
};
if let Some(n) = next {
self.screenshots.viewing = Some(n);
self.screenshots.confirm_delete = false;
self.screenshots.note = None;
}
true
}
fn copy_screenshot(&mut self, shot: &Shot, cx: &mut Context<Self>) {
let format = match shot.path.extension().and_then(|x| x.to_str()) {
Some(x) if x.eq_ignore_ascii_case("png") => ImageFormat::Png,
_ => ImageFormat::Jpeg,
};
self.screenshots.note = Some(match std::fs::read(&shot.path) {
Ok(bytes) => {
cx.write_to_clipboard(ClipboardItem::new_image(&Image::from_bytes(format, bytes)));
Ok("Copied to the clipboard.".into())
}
Err(e) => Err(format!("Could not read {}: {e}", shot.name)),
});
}
fn delete_screenshot(&mut self, shot: &Shot) {
self.screenshots.confirm_delete = false;
self.screenshots.note =
Some(screenshots::delete(shot).map(|()| format!("{} moved to the trash.", shot.name)));
self.screenshots.stale();
}
/// Home's Screenshots section, for the selected instance; None while it has none.
pub(super) fn screenshots_section(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let home = self.selected_instance()?.home.clone();
self.load_screenshots(home.clone());
let list = self.screenshots.list.clone();
if list.is_empty() {
return None;
}
let shown = if self.screenshots.all {
list.len()
} else {
FIRST
};
let folder = screenshots::folder(&home);
let mut buttons = div().flex().items_center().gap(px(6.));
if list.len() > FIRST {
let label = match self.screenshots.all {
true => "Show fewer".to_string(),
false => format!("Show all {}", list.len()),
};
buttons = buttons.child(button("screenshots-all", label, Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.screenshots.all = !this.screenshots.all;
cx.notify();
}),
));
}
buttons = buttons.child(
button("screenshots-folder", "Open folder", Kind::Ghost, &t)
.on_click(move |_, _, cx| cx.open_with_system(&folder)),
);
let header = div()
.flex()
.items_center()
.justify_between()
.mb(px(4.))
.child(section_title("Screenshots", "image", &t))
.child(buttons);
let grid = div().flex().flex_wrap().gap(px(10.)).children(
list.into_iter().take(shown).enumerate().map(|(i, shot)| {
div()
.id(SharedString::from(format!("screenshot-{i}")))
.flex_none()
.w(px(THUMB_W))
.h(px(THUMB_H))
.border_2()
.border_color(t.outline)
.bg(t.well)
.cursor_pointer()
.hover(|s| s.border_color(t.accent))
.child(
gpui::img(shot.path.clone())
.size_full()
.object_fit(gpui::ObjectFit::Cover),
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.screenshots.viewing = Some(i);
this.screenshots.confirm_delete = false;
this.screenshots.note = None;
cx.notify();
}))
}),
);
let mut section = div().flex().flex_col().gap(px(8.)).child(header);
if self.screenshots.viewing.is_none() {
if let Some(Err(e)) = &self.screenshots.note {
section = section.child(notice(e.clone(), &t));
}
}
Some(section.child(grid))
}
/// The viewer over the whole window, while a picture is open.
pub(super) fn screenshot_viewer(&mut self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
let t = self.theme;
let i = self.screenshots.viewing?;
let shot = self.screenshots.list.get(i)?.clone();
let count = self.screenshots.list.len();
let taken: chrono::DateTime<chrono::Local> = shot.taken.into();
let step = |id: &'static str, label: &'static str, older: bool, can: bool| {
let b = button(id, label, Kind::Ghost, &t);
if can {
b.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.screenshot_step(older);
cx.notify();
}))
} else {
disabled(b)
}
};
let mut actions = div().flex().items_center().gap(px(6.));
if self.screenshots.confirm_delete {
let gone = shot.clone();
actions =
actions
.child(
text(theme::CAPTION)
.text_color(t.danger)
.child("Move it to the trash?"),
)
.child(button("screenshot-keep", "Keep", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.screenshots.confirm_delete = false;
cx.notify();
}),
))
.child(
button("screenshot-trash", "Move to trash", Kind::Secondary, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
this.delete_screenshot(&gone);
cx.notify();
}),
),
);
} else {
let (copy, open, show) = (shot.clone(), shot.path.clone(), shot.path.clone());
actions =
actions
.child(button("screenshot-copy", "Copy", Kind::Ghost, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
this.copy_screenshot(&copy, cx);
cx.notify();
}),
))
.child(
button("screenshot-open", "Open", Kind::Ghost, &t)
.on_click(move |_, _, cx| cx.open_with_system(&open)),
)
.child(
button("screenshot-show", "Show in folder", Kind::Ghost, &t)
.on_click(move |_, _, cx| cx.reveal_path(&show)),
)
.child(
button("screenshot-delete", "Delete", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.screenshots.confirm_delete = true;
cx.notify();
}),
),
);
}
let note = match &self.screenshots.note {
Some(Ok(m)) => Some(text(theme::CAPTION).text_color(t.muted).child(m.clone())),
Some(Err(e)) => Some(text(theme::CAPTION).text_color(t.danger).child(e.clone())),
None => None,
};
let top = div()
.flex()
.items_center()
.gap(px(12.))
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
blocks::pixel(17., FontWeight::MEDIUM)
.text_color(t.ink)
.child(taken.format("%Y-%m-%d %H:%M:%S").to_string()),
)
.child(
text(theme::CAPTION)
.text_color(t.muted)
.truncate()
.child(format!("{} · {} of {count}", shot.name, i + 1)),
),
)
.child(step("screenshot-newer", "← Newer", false, i > 0))
.child(step("screenshot-older", "Older →", true, i + 1 < count))
.child(
button("screenshot-close", "Close", Kind::Secondary, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.screenshot_escape();
cx.notify();
},
)),
);
let bottom = div()
.flex()
.items_center()
.justify_between()
.gap(px(12.))
.child(div().children(note))
.child(actions);
Some(
div()
.id("screenshot-viewer")
.absolute()
.top_0()
.left_0()
.size_full()
.flex()
.flex_col()
.gap(px(14.))
.px(px(32.))
.py(px(24.))
.bg(t.page.opacity(0.96))
.on_scroll_wheel(|_, _, cx| cx.stop_propagation())
.occlude()
.child(top)
.child(
// The picture fills what the bars leave, whatever its own size.
div()
.relative()
.flex_1()
.min_h_0()
.border_2()
.border_color(t.outline)
.bg(t.well)
.child(
gpui::img(shot.path.clone())
.absolute()
.top_0()
.left_0()
.size_full()
.object_fit(gpui::ObjectFit::Contain),
),
)
.child(bottom),
)
}
}
+159
View File
@@ -0,0 +1,159 @@
//! The player's server list, `servers.dat` in an instance's game folder: the game's Multiplayer
//! screen reads and writes the same file. Its format is vanilla's (`ServerList.load`/`save`,
//! `ServerData.read`/`write`): uncompressed NBT, `servers` a list of compounds with `name`, `ip`,
//! an optional `icon` (a PNG in base64), `acceptTextures`, `acceptedCodeOfConduct` and `hidden`.
//! The launcher only adds and removes entries: every other field of every entry is written back
//! as it was read.
use base64::Engine;
use horizon_world::nbt::{self, Compound, JavaString, Limits, NamedTag, Tag};
use std::fs;
use std::path::Path;
const FILE: &str = "servers.dat";
#[derive(Clone, Debug, PartialEq)]
pub struct Server {
pub name: String,
pub ip: String,
/// The server's picture, as the game last saw it (PNG).
pub icon: Option<Vec<u8>>,
}
/// The entries as they are in the file, the hidden ones (servers joined with Direct
/// Connection) too.
pub struct List {
entries: Vec<Compound>,
}
fn key(k: &str) -> JavaString {
JavaString::from(k)
}
fn text(entry: &Compound, k: &str) -> String {
match entry.get(&key(k)) {
Some(Tag::String(s)) => s.to_string_lossy(),
_ => String::new(),
}
}
fn hidden(entry: &Compound) -> bool {
matches!(entry.get(&key("hidden")), Some(Tag::Byte(b)) if *b != 0)
}
impl List {
/// The list in `home`; empty when there is none or it can't be read, as the game does.
pub fn load(home: &Path) -> List {
let entries = fs::read(home.join(FILE))
.ok()
.and_then(|bytes| nbt::read_named(&bytes, Limits::default()).ok())
.and_then(|(root, _)| match root.tag.get("servers") {
Some(Tag::List(list)) => Some(
list.iter()
.filter_map(|t| match t {
Tag::Compound(c) => Some(c.clone()),
_ => None,
})
.collect(),
),
_ => None,
})
.unwrap_or_default();
List { entries }
}
/// The servers the Multiplayer screen shows, in its order.
pub fn servers(&self) -> Vec<Server> {
self.entries
.iter()
.filter(|e| !hidden(e))
.map(|e| Server {
name: text(e, "name"),
ip: text(e, "ip"),
icon: Some(text(e, "icon"))
.filter(|s| !s.is_empty())
.and_then(|s| base64::engine::general_purpose::STANDARD.decode(s).ok()),
})
.collect()
}
/// Adds a server at the end of the shown ones, as the game's Add Server does.
pub fn add(&mut self, name: &str, ip: &str) -> Result<(), String> {
let (name, ip) = (name.trim(), ip.trim());
if ip.is_empty() {
return Err("A server needs an address.".into());
}
let name = if name.is_empty() {
"Minecraft Server"
} else {
name
};
let at = self
.entries
.iter()
.rposition(|e| !hidden(e))
.map_or(0, |i| i + 1);
let entry: Compound = [
(key("name"), Tag::String(name.into())),
(key("ip"), Tag::String(ip.into())),
(key("hidden"), Tag::Byte(0)),
]
.into_iter()
.collect();
self.entries.insert(at, entry);
Ok(())
}
/// Removes the `index`th shown server.
pub fn remove(&mut self, index: usize) -> Result<Server, String> {
let server = self
.servers()
.into_iter()
.nth(index)
.ok_or_else(|| format!("There is no server {}.", index + 1))?;
let at = (0..self.entries.len())
.filter(|&i| !hidden(&self.entries[i]))
.nth(index)
.unwrap();
self.entries.remove(at);
Ok(server)
}
/// Writes the list as the game does: a new file first, the old one kept as `servers.dat_old`.
pub fn save(&self, home: &Path) -> Result<(), String> {
let root = NamedTag {
name: "".into(),
tag: Tag::compound([(
"servers",
Tag::List(self.entries.iter().cloned().map(Tag::Compound).collect()),
)]),
};
let bytes = nbt::write_named(&root, Limits::default()).map_err(|e| e.to_string())?;
let (new, current) = (home.join("servers.dat_new"), home.join(FILE));
let written = fs::create_dir_all(home)
.and_then(|()| fs::write(&new, bytes))
.and_then(|()| match current.exists() {
true => fs::copy(&current, home.join("servers.dat_old")).map(|_| ()),
false => Ok(()),
})
.and_then(|()| fs::rename(&new, &current));
written.map_err(|e| format!("Could not save the server list: {e}"))
}
}
/// The shown server a command names: its position from 1, its name (any case) or its address.
pub fn find(list: &List, query: &str) -> Result<(usize, Server), String> {
let servers = list.servers();
let q = query.trim();
q.parse::<usize>()
.ok()
.filter(|n| (1..=servers.len()).contains(n))
.map(|n| n - 1)
.or_else(|| servers.iter().position(|s| s.name.eq_ignore_ascii_case(q)))
.or_else(|| servers.iter().position(|s| s.ip.eq_ignore_ascii_case(q)))
.map(|i| (i, servers[i].clone()))
.ok_or_else(|| format!("No server {q} in the list"))
}
#[cfg(test)]
#[path = "server_list/tests.rs"]
mod tests;
+143
View File
@@ -0,0 +1,143 @@
use super::*;
use std::path::PathBuf;
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"horizon-launcher-servers-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
fn s(v: &str) -> Tag {
Tag::String(v.into())
}
/// A servers.dat as the game writes it: one shown server with a picture and the fields the
/// launcher doesn't use, then one joined through Direct Connection.
fn vanilla_file(home: &Path) -> Vec<u8> {
let png = [0x89, b'P', b'N', b'G', 1, 2, 3];
let icon = base64::engine::general_purpose::STANDARD.encode(png);
let root = NamedTag {
name: "".into(),
tag: Tag::compound([(
"servers",
Tag::List(vec![
Tag::compound([
("name", s("Friends")),
("ip", s("friends.example:25570")),
("icon", s(&icon)),
("acceptTextures", Tag::Byte(1)),
("acceptedCodeOfConduct", Tag::Int(-12345)),
("hidden", Tag::Byte(0)),
]),
Tag::compound([
("name", s("Minecraft Server")),
("ip", s("direct.example")),
("hidden", Tag::Byte(1)),
]),
]),
)]),
};
let bytes = nbt::write_named(&root, Limits::default()).unwrap();
fs::write(home.join(FILE), &bytes).unwrap();
bytes
}
fn entries(home: &Path) -> Vec<Tag> {
let bytes = fs::read(home.join(FILE)).unwrap();
match nbt::read_named(&bytes, Limits::default())
.unwrap()
.0
.tag
.get("servers")
{
Some(Tag::List(list)) => list.clone(),
other => panic!("no servers list: {other:?}"),
}
}
#[test]
fn reads_the_shown_servers_with_their_pictures() {
let home = scratch("reads");
vanilla_file(home.as_path());
let list = List::load(home.as_path());
assert_eq!(
list.servers(),
vec![Server {
name: "Friends".into(),
ip: "friends.example:25570".into(),
icon: Some(vec![0x89, b'P', b'N', b'G', 1, 2, 3]),
}]
);
// No file, or one that isn't NBT: an empty list, as in the game.
assert!(List::load(&home.as_path().join("none"))
.servers()
.is_empty());
fs::write(home.as_path().join(FILE), b"not nbt").unwrap();
assert!(List::load(home.as_path()).servers().is_empty());
}
#[test]
fn adding_and_removing_keeps_every_other_field_and_the_hidden_servers() {
let home = scratch("keeps");
let original = vanilla_file(home.as_path());
let before = entries(home.as_path());
let mut list = List::load(home.as_path());
list.add(" Hub ", " hub.example ").unwrap();
assert!(list.add("x", " ").is_err());
list.save(home.as_path()).unwrap();
// The game's own backup of what was there.
assert_eq!(
fs::read(home.as_path().join("servers.dat_old")).unwrap(),
original
);
assert!(!home.as_path().join("servers.dat_new").exists());
let after = entries(home.as_path());
assert_eq!(after.len(), 3);
assert_eq!(
after[0], before[0],
"the first server is written back untouched"
);
assert_eq!(
after[1],
Tag::compound([
("name", s("Hub")),
("ip", s("hub.example")),
("hidden", Tag::Byte(0)),
]),
"a new server goes after the shown ones, before the hidden ones"
);
assert_eq!(after[2], before[1], "the hidden server stays");
let mut list = List::load(home.as_path());
let (i, found) = find(&list, "friends").unwrap();
assert_eq!((i, found.ip.as_str()), (0, "friends.example:25570"));
assert_eq!(find(&list, "2").unwrap().1.name, "Hub");
assert_eq!(find(&list, "HUB.example").unwrap().0, 1);
assert!(find(&list, "3").is_err(), "the hidden one can't be named");
assert_eq!(list.remove(0).unwrap().name, "Friends");
assert!(list.remove(5).is_err());
list.save(home.as_path()).unwrap();
assert_eq!(
entries(home.as_path()),
vec![after[1].clone(), before[1].clone()]
);
}
#[test]
fn a_server_without_a_name_gets_the_games_default() {
let home = scratch("default");
let mut list = List::load(home.as_path());
list.add("", "play.example").unwrap();
list.save(home.as_path()).unwrap();
assert!(!home.as_path().join("servers.dat_old").exists());
assert_eq!(
List::load(home.as_path()).servers()[0].name,
"Minecraft Server"
);
}
+7 -1
View File
@@ -35,10 +35,13 @@ pub(super) enum Field {
/// The instance dialog's (`instances_page.rs`).
InstanceName,
InstanceGroup,
/// Home's Multiplayer section's (`multiplayer_page.rs`).
ServerName,
ServerAddress,
}
impl Field {
const ALL: [Field; 11] = [
const ALL: [Field; 13] = [
Field::Name,
Field::Seed,
Field::Players,
@@ -50,6 +53,8 @@ impl Field {
Field::Email,
Field::InstanceName,
Field::InstanceGroup,
Field::ServerName,
Field::ServerAddress,
];
}
@@ -1027,6 +1032,7 @@ impl Launcher {
Field::Token => self.save_token(),
Field::Email => self.invite_friend(),
Field::InstanceName | Field::InstanceGroup => {}
Field::ServerName | Field::ServerAddress => self.add_server(),
}
}
+273
View File
@@ -0,0 +1,273 @@
//! An instance's single-player worlds (`saves/` in its game folder): what each is, and backing
//! up, copying and deleting them. Backups are made as vanilla's Make Backup makes them
//! (`LevelStorageAccess.makeWorldBackup`): `backups/<yyyy-MM-dd_HH-mm-ss>_<folder>.zip` in the
//! game folder, every file under `<folder>/` but the world's `session.lock`.
use horizon_world::nbt::{self, Limits, Tag};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// The lock a running game holds on a world; vanilla's backups leave it out.
const LOCK: &str = "session.lock";
#[derive(Clone, Debug, PartialEq)]
pub struct World {
/// Its folder's name under `saves/`, the id commands take.
pub folder: String,
pub path: PathBuf,
/// `LevelName`, else the folder's name.
pub name: String,
/// Survival, Creative, Adventure, Spectator or Hardcore.
pub mode: &'static str,
/// `LastPlayed`, else when level.dat changed.
pub last_played: SystemTime,
/// The version that last saved it (`Version.Name`).
pub version: Option<String>,
/// Bytes on disk, every file in its folder.
pub size: u64,
/// `icon.png`, the picture the game took of it.
pub icon: Option<PathBuf>,
}
/// The worlds in a game folder's `saves/`, the last played first.
pub fn list(home: &Path) -> Vec<World> {
let mut worlds: Vec<World> = fs::read_dir(home.join("saves"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| read(&e.path()))
.collect();
worlds.sort_by_key(|w| std::cmp::Reverse(w.last_played));
worlds
}
/// level.dat: gzip, as vanilla writes it, or plain.
fn level_dat(path: &Path) -> Option<Tag> {
let bytes = fs::read(path).ok()?;
let mut raw = Vec::new();
let raw = match flate2::read::GzDecoder::new(&bytes[..])
.take(64 << 20)
.read_to_end(&mut raw)
{
Ok(_) => raw,
Err(_) => bytes,
};
Some(nbt::read_named(&raw, Limits::default()).ok()?.0.tag)
}
fn read(path: &Path) -> Option<World> {
let dat = path.join("level.dat");
let changed = fs::metadata(&dat).and_then(|m| m.modified()).ok()?;
let folder = path.file_name()?.to_string_lossy().into_owned();
let root = level_dat(&dat);
let data = root.as_ref().and_then(|r| r.get("Data"));
let field = |key: &str| data.and_then(|d| d.get(key));
let number = |tag: Option<&Tag>| match tag {
Some(Tag::Byte(v)) => i64::from(*v),
Some(Tag::Int(v)) => i64::from(*v),
Some(Tag::Long(v)) => *v,
_ => 0,
};
let text = |tag: Option<&Tag>| match tag {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
// 26.3 keeps hardcore with the difficulty; older saves at the top of Data.
let hardcore = number(field("difficulty_settings").and_then(|d| d.get("hardcore"))) != 0
|| number(field("hardcore")) != 0;
let mode = match (hardcore, number(field("GameType"))) {
(true, _) => "Hardcore",
(_, 1) => "Creative",
(_, 2) => "Adventure",
(_, 3) => "Spectator",
_ => "Survival",
};
let last_played = match number(field("LastPlayed")) {
millis if millis > 0 => UNIX_EPOCH + Duration::from_millis(millis as u64),
_ => changed,
};
let icon = path.join("icon.png");
Some(World {
name: text(field("LevelName"))
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| folder.clone()),
folder,
path: path.to_path_buf(),
mode,
last_played,
version: text(field("Version").and_then(|v| v.get("Name"))),
size: size(path),
icon: icon.is_file().then_some(icon),
})
}
fn size(dir: &Path) -> u64 {
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| match e.file_type() {
Ok(t) if t.is_dir() => size(&e.path()),
Ok(_) => e.metadata().map_or(0, |m| m.len()),
Err(_) => 0,
})
.sum()
}
/// The world in `home` a command names: its folder, or its name (any case).
pub fn find(home: &Path, query: &str) -> Result<World, String> {
let all = list(home);
all.iter()
.find(|w| w.folder == query)
.or_else(|| all.iter().find(|w| w.name.eq_ignore_ascii_case(query)))
.cloned()
.ok_or_else(|| format!("No world {query} in {}", home.join("saves").display()))
}
/// `base`, else `base (1)`, `base (2)`... from the `first` count: the first name free in `dir`
/// (vanilla's `FileUtil.findAvailableName`, after making the name safe for every file system).
fn free_name(dir: &Path, base: &str, suffix: &str, first: u32) -> String {
let safe: String = base
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let safe = safe.trim_matches(|c: char| c == '.' || c.is_whitespace());
let safe = if safe.is_empty() { "World" } else { safe };
(first..)
.map(|n| match n {
0 => format!("{safe}{suffix}"),
n => format!("{safe} ({n}){suffix}"),
})
.find(|name| !dir.join(name).exists())
.unwrap()
}
/// Zips the world into the game folder's `backups/`, as vanilla's Make Backup does. Returns the
/// zip's path.
pub fn backup(home: &Path, world: &World) -> Result<PathBuf, String> {
backup_at(home, world, chrono::Local::now())
}
fn backup_at(
home: &Path,
world: &World,
now: chrono::DateTime<chrono::Local>,
) -> Result<PathBuf, String> {
let dir = home.join("backups");
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let prefix = format!("{}_{}", now.format("%Y-%m-%d_%H-%M-%S"), world.folder);
let zip_path = dir.join(free_name(&dir, &prefix, ".zip", 0));
let made = (|| -> std::io::Result<()> {
let mut zip = zip::ZipWriter::new(std::io::BufWriter::new(fs::File::create(&zip_path)?));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.large_file(true);
let mut stack = vec![world.path.clone()];
while let Some(dir) = stack.pop() {
let mut entries: Vec<_> = fs::read_dir(&dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());
for e in entries {
let path = e.path();
if e.file_type()?.is_dir() {
stack.push(path);
continue;
}
if path.file_name() == Some(LOCK.as_ref()) {
continue;
}
let relative = path.strip_prefix(&world.path).unwrap_or(&path);
let mut entry = world.folder.clone();
for part in relative.components() {
entry.push('/');
entry.push_str(&part.as_os_str().to_string_lossy());
}
zip.start_file(entry, options)?;
std::io::copy(&mut fs::File::open(&path)?, &mut zip)?;
}
}
zip.finish()?.flush()
})();
made.map(|()| zip_path.clone()).map_err(|e| {
let _ = fs::remove_file(&zip_path);
format!("Could not back up {}: {e}", world.name)
})
}
/// Copies the world into a new folder of the same `saves/`, named "<name> (1)", "<name> (2)"...
/// and gives the copy that name in its level.dat so the game's list tells them apart.
pub fn copy(world: &World) -> Result<World, String> {
let saves = world.path.parent().ok_or("A world folder has a parent")?;
let folder = free_name(saves, &world.name, "", 1);
let to = saves.join(&folder);
let made = copy_dir(&world.path, &to).and_then(|()| rename_level(&to, &folder));
if let Err(e) = made {
let _ = fs::remove_dir_all(&to);
return Err(format!("Could not copy {}: {e}", world.name));
}
read(&to).ok_or_else(|| format!("{} has no level.dat", to.display()))
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
fs::create_dir_all(to)?;
for e in fs::read_dir(from)? {
let e = e?;
let target = to.join(e.file_name());
if e.file_type()?.is_dir() {
copy_dir(&e.path(), &target)?;
} else if e.file_name() != LOCK {
fs::copy(e.path(), target)?;
}
}
Ok(())
}
/// Sets `Data.LevelName`; a level.dat that can't be read is left as it is.
fn rename_level(world: &Path, name: &str) -> std::io::Result<()> {
let dat = world.join("level.dat");
let Some(mut root) = level_dat(&dat) else {
return Ok(());
};
let Some(Tag::Compound(data)) = root.get_mut("Data") else {
return Ok(());
};
data.insert("LevelName".into(), Tag::String(name.into()));
let bytes = nbt::write_named(
&nbt::NamedTag {
name: "".into(),
tag: root,
},
Limits::default(),
)
.map_err(std::io::Error::other)?;
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&bytes)?;
fs::write(dat, gz.finish()?)
}
/// Moves the world to the system's trash, where the player can still get it back.
pub fn delete(world: &World) -> Result<(), String> {
to_trash(&world.path).map_err(|e| format!("Could not move {} to the trash: {e}", world.name))
}
/// Moves a file or folder to the system's trash.
pub(crate) fn to_trash(path: &Path) -> Result<(), trash::Error> {
#[allow(unused_mut)]
let mut trash = trash::TrashContext::default();
// The crate's default on macOS asks Finder through AppleScript, which needs the player to
// allow the launcher to control Finder first; the file manager call needs nothing.
#[cfg(target_os = "macos")]
trash::macos::TrashContextExtMacos::set_delete_method(
&mut trash,
trash::macos::DeleteMethod::NsFileManager,
);
trash.delete(path)
}
#[cfg(test)]
#[path = "worlds/tests.rs"]
mod tests;
+157
View File
@@ -0,0 +1,157 @@
use super::*;
use chrono::TimeZone;
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"horizon-launcher-worlds-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
/// A world as the game saves it: a gzip level.dat with `data`'s fields under `Data`.
fn world(home: &Path, folder: &str, data: Vec<(&str, Tag)>) -> PathBuf {
let dir = home.join("saves").join(folder);
fs::create_dir_all(dir.join("region")).unwrap();
let root = Tag::compound([("Data", Tag::compound(data))]);
let bytes = nbt::write_named(
&nbt::NamedTag {
name: "".into(),
tag: root,
},
Limits::default(),
)
.unwrap();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&bytes).unwrap();
fs::write(dir.join("level.dat"), gz.finish().unwrap()).unwrap();
fs::write(dir.join("region/r.0.0.mca"), [7u8; 1000]).unwrap();
fs::write(dir.join(LOCK), "lock").unwrap();
dir
}
fn name(n: &str) -> Tag {
Tag::String(n.into())
}
#[test]
fn lists_what_each_world_is() {
let home = scratch("list");
let survival = world(
&home,
"Survival",
vec![
("LevelName", name("My survival")),
("GameType", Tag::Int(0)),
("LastPlayed", Tag::Long(1_700_000_000_000)),
("Version", Tag::compound([("Name", name("26.3"))])),
],
);
fs::write(survival.join("icon.png"), "png").unwrap();
world(
&home,
"Hard",
vec![
("LevelName", name("Hard")),
("GameType", Tag::Int(0)),
("LastPlayed", Tag::Long(1_800_000_000_000)),
(
"difficulty_settings",
Tag::compound([("hardcore", Tag::Byte(1))]),
),
],
);
world(
&home,
"Old",
vec![
("GameType", Tag::Int(1)),
("hardcore", Tag::Byte(0)),
("LastPlayed", Tag::Long(1_600_000_000_000)),
],
);
fs::create_dir_all(home.join("saves/not a world")).unwrap();
let all = list(&home);
let names: Vec<&str> = all.iter().map(|w| w.name.as_str()).collect();
assert_eq!(names, ["Hard", "My survival", "Old"]);
assert_eq!(all[0].mode, "Hardcore");
assert_eq!(all[1].mode, "Survival");
assert_eq!(all[1].folder, "Survival");
assert_eq!(all[1].version.as_deref(), Some("26.3"));
assert_eq!(all[1].icon, Some(survival.join("icon.png")));
assert_eq!(
all[1].last_played,
UNIX_EPOCH + Duration::from_millis(1_700_000_000_000)
);
assert!(all[1].size >= 1000);
// No LevelName: the folder's name.
assert_eq!(all[2].mode, "Creative");
assert_eq!(find(&home, "my SURVIVAL").unwrap().folder, "Survival");
assert!(find(&home, "nope").is_err());
fs::remove_dir_all(&home).unwrap();
}
#[test]
fn a_backup_is_named_and_laid_out_as_vanillas() {
let home = scratch("backup");
world(&home, "World", vec![("LevelName", name("World"))]);
let w = find(&home, "World").unwrap();
let at = chrono::Local
.with_ymd_and_hms(2026, 10, 27, 1, 2, 3)
.unwrap();
let first = backup_at(&home, &w, at).unwrap();
assert_eq!(first, home.join("backups/2026-10-27_01-02-03_World.zip"));
// The same second again: the next free name.
let second = backup_at(&home, &w, at).unwrap();
assert_eq!(
second,
home.join("backups/2026-10-27_01-02-03_World (1).zip")
);
let mut zip = zip::ZipArchive::new(fs::File::open(&first).unwrap()).unwrap();
let mut entries: Vec<String> = zip.file_names().map(str::to_string).collect();
entries.sort();
assert_eq!(entries, ["World/level.dat", "World/region/r.0.0.mca"]);
let mut region = Vec::new();
zip.by_name("World/region/r.0.0.mca")
.unwrap()
.read_to_end(&mut region)
.unwrap();
assert_eq!(region, [7u8; 1000]);
fs::remove_dir_all(&home).unwrap();
}
#[test]
fn a_copy_gets_the_next_free_name_in_its_folder_and_level_dat() {
let home = scratch("copy");
world(
&home,
"Base",
vec![("LevelName", name("Base")), ("GameType", Tag::Int(1))],
);
let base = find(&home, "Base").unwrap();
let copy1 = copy(&base).unwrap();
assert_eq!(copy1.folder, "Base (1)");
assert_eq!(copy1.name, "Base (1)");
assert_eq!(copy1.mode, "Creative");
assert!(!copy1.path.join(LOCK).exists());
assert_eq!(
fs::read(copy1.path.join("region/r.0.0.mca")).unwrap(),
[7u8; 1000]
);
assert_eq!(copy(&base).unwrap().folder, "Base (2)");
// The original is untouched.
assert_eq!(find(&home, "Base").unwrap(), base);
// A name that can't be a folder name is made safe.
assert_eq!(free_name(&home, "a/b: c?", "", 0), "a_b_ c_");
// A world whose folder isn't its name: the copy still gets a new name.
world(&home, "f", vec![("LevelName", name("Named"))]);
let named = copy(&find(&home, "f").unwrap()).unwrap();
assert_eq!(
(named.folder.as_str(), named.name.as_str()),
("Named (1)", "Named (1)")
);
fs::remove_dir_all(&home).unwrap();
}
+311
View File
@@ -0,0 +1,311 @@
//! The selected instance's worlds on Home (`horizon_launcher::worlds`): each with its picture,
//! mode, version, when it was last played and its size, and Back up, Copy, Delete (to the
//! trash, after a confirmation) and Open folder. Reading sizes and copying run in the background.
use super::*;
use horizon_launcher::worlds::{self, World};
use std::path::{Path, PathBuf};
#[derive(Default)]
pub(super) struct Worlds {
/// The game folder the list is of, and the list as last read: kept on screen while it is
/// read again, so the page doesn't jump.
home: Option<PathBuf>,
list: Vec<World>,
fresh: bool,
reading: bool,
/// The world being backed up, copied or deleted now.
busy: Option<String>,
confirm_delete: Option<String>,
/// What the last action did (a backup's zip), or why it failed.
done: Option<Result<(String, Option<PathBuf>), String>>,
}
impl Worlds {
/// Read again on the next frame: the game may have saved, or an action changed the list.
pub(super) fn stale(&mut self) {
self.fresh = false;
}
}
/// "today", "yesterday", "3 days ago", else the date.
pub(super) fn ago(when: std::time::SystemTime) -> String {
let when: chrono::DateTime<chrono::Local> = when.into();
let days = (chrono::Local::now().date_naive() - when.date_naive()).num_days();
match days {
..=0 => "today".into(),
1 => "yesterday".into(),
2..=13 => format!("{days} days ago"),
_ => when.format("%Y-%m-%d").to_string(),
}
}
enum Action {
Backup,
Copy,
Delete,
}
impl Launcher {
/// Reads the worlds of `home` in the background when the list is missing or of another
/// instance.
fn load_worlds(&mut self, home: PathBuf, cx: &mut Context<Self>) {
if self.worlds.home.as_ref() != Some(&home) {
self.worlds = Worlds {
home: Some(home.clone()),
..Worlds::default()
};
}
if self.worlds.fresh || self.worlds.reading {
return;
}
self.worlds.reading = true;
let read = home.clone();
let task = cx
.background_executor()
.spawn(async move { worlds::list(&read) });
cx.spawn(async move |this, cx| {
let list = task.await;
let _ = this.update(cx, |this, cx| {
// Another instance may have been picked meanwhile.
if this.worlds.home.as_ref() == Some(&home) {
this.worlds.list = list;
this.worlds.fresh = true;
this.worlds.reading = false;
}
cx.notify();
});
})
.detach();
}
fn world_action(&mut self, action: Action, world: World, cx: &mut Context<Self>) {
let Some(home) = self.worlds.home.clone() else {
return;
};
if self.worlds.busy.is_some() {
return;
}
self.worlds.busy = Some(world.folder.clone());
self.worlds.confirm_delete = None;
self.worlds.done = None;
let task = cx.background_executor().spawn(async move {
match action {
Action::Backup => worlds::backup(&home, &world)
.map(|zip| (format!("{} backed up.", world.name), Some(zip))),
Action::Copy => {
worlds::copy(&world).map(|c| (format!("Copied to {}.", c.name), None))
}
Action::Delete => worlds::delete(&world)
.map(|()| (format!("{} moved to the trash.", world.name), None)),
}
});
cx.spawn(async move |this, cx| {
let done = task.await;
let _ = this.update(cx, |this, cx| {
this.worlds.busy = None;
this.worlds.done = Some(done);
this.worlds.stale();
cx.notify();
});
})
.detach();
}
/// Home's Worlds section, for the selected instance.
pub(super) fn worlds_section(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let home = self.selected_instance()?.home.clone();
self.load_worlds(home.clone(), cx);
let list = self.worlds.list.clone();
let mut section = div()
.flex()
.flex_col()
.gap(px(8.))
.child(section_title("Worlds", "globe", &t).mb(px(4.)));
match &self.worlds.done {
Some(Ok((message, zip))) => {
let mut line = div().flex().items_center().gap(px(12.)).child(
text(theme::CAPTION)
.text_color(t.muted)
.child(message.clone()),
);
if let Some(zip) = zip.clone() {
let dir = zip.parent().map(Path::to_path_buf).unwrap_or(zip);
line = line.child(
button("world-backups", "Open backups", Kind::Ghost, &t)
.on_click(move |_, _, cx| cx.open_with_system(&dir)),
);
}
section = section.child(line);
}
Some(Err(e)) => section = section.child(notice(e.clone(), &t)),
None => {}
}
if list.is_empty() {
let saves = home.join("saves");
return Some(
section.child(
div()
.flex()
.items_center()
.justify_between()
.child(
text(theme::CAPTION)
.text_color(t.muted)
.child("No worlds yet: they appear here once you play one."),
)
.child(
button("worlds-folder", "Open folder", Kind::Ghost, &t).on_click(
move |_, _, cx| {
let _ = std::fs::create_dir_all(&saves);
cx.open_with_system(&saves);
},
),
),
),
);
}
for (i, w) in list.into_iter().enumerate() {
section = section.child(self.world_row(i, w, cx));
}
Some(section)
}
fn world_row(&mut self, i: usize, w: World, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let busy = self.worlds.busy.is_some();
let this_busy = self.worlds.busy.as_ref() == Some(&w.folder);
let confirming = self.worlds.confirm_delete.as_ref() == Some(&w.folder);
let picture = match &w.icon {
Some(icon) => div()
.flex_none()
.size(px(48.))
.border_2()
.border_color(t.outline)
.child(gpui::img(icon.clone()).size(px(44.)))
.into_any_element(),
None => div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(48.))
.border_2()
.border_color(t.outline)
.bg(t.well)
.child(blocks::icon("image", 22., t.muted))
.into_any_element(),
};
let mut facts = vec![w.mode.to_string()];
facts.extend(w.version.clone());
facts.push(ago(w.last_played));
facts.push(megabytes(w.size));
let act = |id: &str, label: &'static str, kind: Kind| {
let b = button(SharedString::from(format!("{id}-{i}")), label, kind, &t);
if busy {
disabled(b)
} else {
b
}
};
let mut actions = div().flex().items_center().gap(px(6.));
if confirming {
let gone = w.clone();
actions = actions
.child(
text(theme::CAPTION)
.text_color(t.danger)
.child("Move it to the trash?"),
)
.child(act("world-keep", "Keep", Kind::Ghost).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.worlds.confirm_delete = None;
cx.notify();
},
)))
.child(
act("world-trash", "Move to trash", Kind::Secondary).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.world_action(Action::Delete, gone.clone(), cx);
cx.notify();
},
)),
);
} else if this_busy {
actions = actions.child(text(theme::CAPTION).text_color(t.muted).child("Working…"));
} else {
let (backup, copy, folder) = (w.clone(), w.clone(), w.path.clone());
let (delete, play) = (w.folder.clone(), w.folder.clone());
let running = self.client.is_some() || self.starting_client.is_some();
let play_button = act("world-play", "Play", Kind::Secondary);
let play_button = if running {
disabled(play_button)
} else {
play_button
};
actions = actions
.child(
play_button.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.quick_play("--world", play.clone());
cx.notify();
})),
)
.child(
act("world-backup", "Back up", Kind::Ghost).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.world_action(Action::Backup, backup.clone(), cx);
cx.notify();
},
)),
)
.child(act("world-copy", "Copy", Kind::Ghost).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.world_action(Action::Copy, copy.clone(), cx);
cx.notify();
},
)))
.child(
act("world-delete", "Delete", Kind::Ghost).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
// Buttons look disabled while something runs, but still click.
if this.worlds.busy.is_none() {
this.worlds.confirm_delete = Some(delete.clone());
}
cx.notify();
},
)),
)
.child(
act("world-folder", "Open folder", Kind::Ghost)
.on_click(move |_, _, cx| cx.open_with_system(&folder)),
);
}
plate(&t)
.flex()
.items_center()
.gap(px(14.))
.px(px(14.))
.py(px(10.))
.child(picture)
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
blocks::pixel(15., FontWeight::MEDIUM)
.text_color(t.ink)
.truncate()
.child(w.name.clone()),
)
.child(
text(theme::CAPTION)
.text_color(t.muted)
.truncate()
.child(facts.join(" · ")),
),
)
.child(actions)
}
}
+97 -9
View File
@@ -2971,13 +2971,15 @@ def check_fireworks(host, guest, out):
game('command', command=f'tp @s {x + dx + 0.5} {y} {z + 0.5}')
game.raw('look-at', x + 0.5, y + 1.5, z + 3.5)
def red(path):
def reds(path):
"""The red pixels' positions, above the hotbar."""
rows, bpp = png_rows(path)
# Exclude the hotbar and chat at the bottom of the view.
return sum(row[i] > 80 and row[i] > 2 * row[i + 1] and row[i] > 2 * row[i + 2]
for row in rows[:len(rows) * 3 // 4] for i in range(0, len(row), bpp))
return {(y, i) for y, row in enumerate(rows[:len(rows) * 3 // 4]) for i in range(0, len(row), bpp)
if row[i] > 80 and row[i] > 2 * row[i + 1] and row[i] > 2 * row[i + 2]}
baseline = {g.name: red(g.shot(out, 'fireworks-before')) for g in (host, guest)}
# Red where the view had none: the setup's own red chat lines (a fill that changed nothing) are in the
# baseline, and fade out before the later shots, which a plain count difference would take as missing sparks.
baseline = {g.name: reds(g.shot(out, 'fireworks-before')) for g in (host, guest)}
before = {g.name: made(g, 'flash') for g in (host, guest)}
host('command', command='item replace entity @s weapon.mainhand with minecraft:firework_rocket'
'[fireworks={flight_duration:0,explosions:[{shape:"large_ball",colors:[I;16711680],has_trail:true}]}]')
@@ -2990,12 +2992,14 @@ def check_fireworks(host, guest, out):
break
flashes = {g.name: made(g, 'flash') - before[g.name] for g in (host, guest)}
assert all(n == 1 for n in flashes.values()), f'configured burst missing or duplicated: {flashes}'
# Capture both views before decoding PNGs: decoding must not consume the sparks' lifetime.
# Capture both views before decoding PNGs: decoding must not consume the sparks' lifetime (48-59 ticks). A
# screenshot takes ~0.6 s where the frame is drawn on the CPU (lavapipe), so the later shots come 4 ticks after
# the burst's, not more: by then the tracked rocket is gone.
burst_shots = {g.name: g.shot(out, 'fireworks-burst') for g in (host, guest)}
host.wait(16)
host.wait(4)
trail_shots = {g.name: g.shot(out, 'fireworks-trail-fade') for g in (host, guest)}
burst = {name: red(path) - baseline[name] for name, path in burst_shots.items()}
trails = {name: red(path) - baseline[name] for name, path in trail_shots.items()}
burst = {name: len(reds(path) - baseline[name]) for name, path in burst_shots.items()}
trails = {name: len(reds(path) - baseline[name]) for name, path in trail_shots.items()}
rockets = {g.name: g('status')['rockets'] for g in (host, guest)}
assert not any(rockets.values()), f'exploded rocket remains: {rockets}'
assert all(n > 20 for n in burst.values()), f'no red burst: {burst}'
@@ -7149,6 +7153,90 @@ CHECKS += ['guardian-beams']
CHECK_FNS['guardian-beams'] = check_guardian_beams
def check_fangs(host, guest, out):
"""EvokerFangs: authoritative spawn, event 4, client animation and server removal (#1482)."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'lan_evoker_fangs'
selector = f'@e[type=minecraft:evoker,tag={tag},limit=1]'
paths = {g.name: {} for g in (host, guest)}
warmup = dict.fromkeys(paths, False)
drawn = dict.fromkeys(paths, False)
stopped = False
photographed = False
def fangs(status):
return [p for p in status['projectiles'] if p['kind'] == 'minecraft:evoker_fangs']
try:
host('command', command='difficulty normal')
host('command', command='gamemode creative')
guest('command', command='gamemode survival')
guest('command', command='effect give @s minecraft:resistance 120 4 true')
host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone')
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 8} {z + 18} minecraft:air')
host('command', command=f'tp @s {x + 4.5} {y} {z - 2.5}')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
guest.wait(10)
host('command', command=f'summon minecraft:evoker {x + 0.5} {y} {z + 9.5} '
f'{{PersistenceRequired:1b,Tags:["{tag}"]}}')
for game in (host, guest):
game.raw('look-at', x + 0.5, y + 0.5, z + 5.5)
for _ in range(200):
guest.wait(2)
statuses = {g.name: g('status') for g in (host, guest)}
for name, status in statuses.items():
live = fangs(status)
ids = [f['id'] for f in live]
assert all(ids) and len(ids) == len(set(ids)), f'{name}: invalid fang ids {ids}'
warmup[name] |= any(not f['started'] for f in live)
drawn[name] |= status['fangs_vertices'] > 0
for f in live:
paths[name].setdefault(f['id'], []).append(f)
if paths['host'] and not stopped:
host('command', command=f'data merge entity {selector} {{NoAI:1b}}')
stopped = True
if (all(drawn.values()) and not photographed
and any(f['started'] and 8 <= f['life'] <= 16
for f in fangs(statuses['guest']))):
host('command', command='tick freeze')
try:
for game in (host, guest):
game.raw('key', 'f1', 'tap')
try:
game.shot(out, 'evoker-fangs')
finally:
game.raw('key', 'f1', 'tap')
finally:
host('command', command='tick unfreeze')
photographed = True
if stopped and not any(fangs(s) for s in statuses.values()):
break
lab.write_json(out / 'evoker-fangs.json', {'paths': paths, 'warmup': warmup, 'drawn': drawn})
assert all(warmup.values()), f'fang warmup not received: {warmup}'
assert all(drawn.values()), f'fang renderer produced no geometry: {drawn}'
assert paths['host'].keys() == paths['guest'].keys(), 'clients received different fang ids'
for id, samples in paths['host'].items():
other = paths['guest'][id]
assert all(f['position'] == samples[0]['position'] for f in samples + other), \
f'fang {id} is not at its authoritative position'
for name, frames in (('host', samples), ('guest', other)):
life = {f['life'] for f in frames if f['started']}
assert len(life) > 1, f'{name}: fang {id} did not animate: {frames}'
assert not any(fangs(g('status')) for g in (host, guest)), 'fangs survived server removal'
finally:
host('command', command='tick unfreeze')
host('command', command=f'kill @e[tag={tag}]')
# The evoker can summon vexes before fangs; killing it leaves those summons alive.
host('command', command=f'kill @e[type=minecraft:vex,x={x},y={y},z={z},distance=..32]')
guest('command', command='effect clear @s')
face_each_other(host, guest)
return {'fangs': len(paths['host']), 'warmup': warmup, 'drawn': drawn, 'removed': True}
CHECKS += ['fangs']
CHECK_FNS.update(fangs=check_fangs)
def check_sonic(host, guest, out):
"""A real warden's ray reaches connected clients once, with normal damage.
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""The End of a dedicated server nobody has entered, force-loaded from the console, against vanilla's.
A vanilla dedicated server and Horizon's each open a copy of one world vanilla generated (its End not made yet).
With no player online, the console force-loads the End's main island and its arena. A FORCED ticket keeps its level active
(`ServerChunkCache.hasActiveTickets`, `TicketType.FORCED`), so the chunks are generated, loaded and ticked
without a player in the dimension. With nobody in the fight, `EnderDragonFight.tick` neither scans the state nor
places the exit portal: no bedrock and no torches around 0, 0. Both servers must answer the same.
"""
import os
import re
import subprocess
import sys
import time
try:
from . import lab, test_rust_parity as base
from .test_dedicated_server_parity import Server, wait_ticks
from .test_locator_bar_parity import generate_world, server_dir
except ImportError:
import lab
import test_rust_parity as base
from test_dedicated_server_parity import Server, wait_ticks
from test_locator_bar_parity import generate_world, server_dir
NAME = 'forced-end-parity'
END = 'execute in minecraft:the_end'
QUERIES = [
f'{END} run fill -3 0 -3 3 255 3 minecraft:air replace minecraft:bedrock',
f'{END} run fill -3 0 -3 3 255 3 minecraft:air replace minecraft:wall_torch',
]
def answer(server, command, n):
start = len(server.lines)
server.console(command)
server.console(f'say done {n}')
server.wait_for(rf'\[Server\] done {n}$', timeout=120)
replies = [l.split(']: ', 1)[-1] for l in server.lines[start:] if 'done ' not in l]
return ' / '.join(r for r in replies if r) or '(no reply)'
def run_side(label, command, build, world):
directory = build / label
server_dir(directory, world)
server = Server(command, directory, label)
try:
server.wait_for(r'Done \(')
# The main island's arena (EnderDragonFight.isArenaLoaded: chunks -8..8), in pieces of at most 256 chunks.
for area in ('-128 -128 143 -1', '-128 0 143 143'):
server.console(f'{END} run forceload add {area}')
deadline = time.time() + 600
for poll in range(1_000_000):
reply = answer(server, f'{END} if loaded 0 0 0', f'loaded {poll}')
if 'passed' in reply:
break
if time.time() > deadline:
raise AssertionError(f'{label}: the End centre never loaded')
time.sleep(2)
wait_ticks(server, 200)
return {q: answer(server, q, i) for i, q in enumerate(QUERIES)}
finally:
server.stop()
def main():
_, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / NAME
build.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
check=True, capture_output=True)
binary = lab.cargo_target() / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
world = generate_world(vanilla_server, build)
vanilla = run_side('vanilla', vanilla_server, build, world)
horizon = run_side('horizon', horizon_server, build, world)
normal = lambda s: re.sub(r'\s+', ' ', s).strip()
problems = [f'{q}\n vanilla: {vanilla[q]}\n horizon: {horizon[q]}'
for q in QUERIES if normal(vanilla[q]) != normal(horizon[q])]
if problems:
raise AssertionError('the forced End differs:\n' + '\n'.join(problems))
print(f'forced End: loaded and ticked with nobody in it, no exit portal, as vanilla ({vanilla[QUERIES[0]]})')
if __name__ == '__main__':
try:
main()
except AssertionError as error:
sys.exit(f'{NAME}: {error}')