diff --git a/.dockerignore b/.dockerignore index 417a1867c..458330c93 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,6 @@ target/ .local/ **/node_modules/ -website/ benchmarks/ packs/ mods/ diff --git a/.gitattributes b/.gitattributes index 358aeae7a..f9e1922d7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,5 @@ # where Git for Windows' core.autocrlf would check them out with CRLF. docs/launcher-commands.md text eol=lf docs/horizon-ctl-commands.md text eol=lf -website/src/tokens.css text eol=lf +crates/horizon-theme/tokens.css text eol=lf docs/api/*-reference.md text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eff82fa99..39ea920da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,42 @@ jobs: run: | cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack + # The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that + # changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass + # alone and break it together (#2059 and the registry ratings did, 2026-10-26). + launcher: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Does this pull request change the launcher or a crate it builds? + if: github.event_name == 'pull_request' + id: launcher + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT" + - name: Install Rust and the headers GPUI links against + if: steps.launcher.outputs.required != 'false' + run: | + sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \ + libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev + rustup toolchain install stable --profile minimal --component rustfmt --component clippy + - uses: Swatinem/rust-cache@v2 + if: steps.launcher.outputs.required != 'false' + with: + workspaces: launcher + shared-key: launcher + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Check the launcher's formatting, lint and tests + if: steps.launcher.outputs.required != 'false' + working-directory: launcher + run: | + cargo +stable fmt --check + cargo +stable clippy --all-targets --locked -- -D warnings + cargo +stable test --locked --lib # Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time # (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes # a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added). diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 60bbbf057..1ea2db6fa 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -8,7 +8,7 @@ name: nightly audit # gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues on: schedule: - # GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris. + # GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:07 in Paris today. - cron: '7 1 * * *' - cron: '7 2 * * *' workflow_dispatch: @@ -31,14 +31,19 @@ jobs: audit: # The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform. runs-on: [self-hosted, horizon-game, macOS] - timeout-minutes: 150 + timeout-minutes: 240 # every harness (~80 min) plus lan_integration (~40 min an attempt under load, retried) steps: - - name: 03:00 in Paris + - name: 03:07 in Paris id: when + env: + SCHEDULE: ${{ github.event.schedule }} run: | - hour=$(TZ=Europe/Paris date +%H) - if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then - echo "It is ${hour}:00 in Paris: the other schedule runs the audit." + # Which cron is 03:07 in Paris, from Paris's offset today: GitHub can start a schedule hours late, so the + # clock at start time can't tell (on 2026-10-26 both started after 04:00 Paris and both skipped). + offset=$(TZ=Europe/Paris date +%z) + ours=$([ "$offset" = +0200 ] && echo '7 1 * * *' || echo '7 2 * * *') + if [[ "${{ github.event_name }}" == schedule && "$SCHEDULE" != "$ours" ]]; then + echo "Paris is at UTC${offset} today: the '$ours' schedule runs the audit." echo "run=false" >> "$GITHUB_OUTPUT" else echo "run=true" >> "$GITHUB_OUTPUT" @@ -66,7 +71,8 @@ jobs: run: | echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT" set +e - python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log" + # lan_integration (the whole lan_e2e suite) takes ~2,400 s under load, past the usual 1,800. + python3 tools/parity.py --all --timeout 3000 2>&1 | tee "$RUNNER_TEMP/audit.log" echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" { echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" - name: Issues for the harnesses that regressed diff --git a/.github/workflows/parity-main.yml b/.github/workflows/parity-main.yml index c47005ab0..70fb33796 100644 --- a/.github/workflows/parity-main.yml +++ b/.github/workflows/parity-main.yml @@ -48,6 +48,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT" - name: The harnesses the merges since then can affect + id: harnesses + # A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD. + continue-on-error: true env: PARITY_HOSTS: '' BASE_SHA: ${{ steps.base.outputs.sha }} @@ -55,8 +58,8 @@ jobs: echo "Merges since ${BASE_SHA}:" git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD" python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 - - name: The merge that broke them - if: failure() + - name: A flake, or the merge that broke them + if: steps.harnesses.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} PARITY_HOSTS: '' @@ -65,7 +68,7 @@ jobs: rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true python3 tools/parity_main.py bisect "$BASE_SHA" --comment - name: Logs of the failed harnesses - if: failure() + if: steps.harnesses.outcome == 'failure' uses: actions/upload-artifact@v4 with: name: parity-main-logs diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 1fc31d8cb..9f678b43b 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -26,35 +26,46 @@ jobs: steps: - uses: actions/checkout@v4 with: - # The commit the event names: a fork's later pushes need another run-parity label. + # The commit the event names: a fork's later pushes need another run-parity label. The next step merges + # main into it. ref: ${{ inputs.head }} fetch-depth: 0 persist-credentials: false # Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs. clean: false + - name: The head merged into main (what merging it would land) + id: merge + env: + BASE_SHA: ${{ inputs.base }} + HEAD_SHA: ${{ inputs.head }} + run: | + # Harness fixes and game fixes that reached main since the branch was made then apply here too: a head + # tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before + # #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull + # request's own changes. A head that conflicts with main is tested alone. + set -euo pipefail + git merge --abort 2>/dev/null || true + git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main + main=$(git rev-parse origin/main) + if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then + echo "base=$main" >> "$GITHUB_OUTPUT" + echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY" + else + git merge --abort + echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes" + echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY" + fi - uses: dtolnay/rust-toolchain@stable - name: Tools from the runner run: bash tools/ci/runner-env.sh - name: Vanilla data, and resources prepared by this preparer - env: - BASE_SHA: ${{ inputs.base }} - run: | - # main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on - # the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own. - set -euo pipefail - if git rev-parse --verify -q origin/main >/dev/null \ - && git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then - git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh" - bash "$RUNNER_TEMP/vanilla-data.sh" - else - bash tools/ci/vanilla-data.sh - fi + run: bash tools/ci/vanilla-data.sh - name: The prepared output is the one the preparer's OUTPUT_VERSION says env: - BASE_SHA: ${{ inputs.base }} + BASE_SHA: ${{ steps.merge.outputs.base }} run: | - # The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither - # the tool nor the number, so there is nothing to check. + # A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number. if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then echo "This head's preparer has no OUTPUT_VERSION: nothing to check" exit 0 @@ -65,47 +76,8 @@ jobs: - name: The harnesses this change can affect env: PARITY_HOSTS: '' - BASE_SHA: ${{ inputs.base }} - run: | - # main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a - # branch made before a better selection landed gets it too; the head's own runner then runs those - # harnesses. A branch that changes the selection itself selects with its own code. - set -euo pipefail - own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner - fork_point=$(git merge-base "$BASE_SHA" HEAD) - if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then - own - exit - fi - had_reach=$(git ls-files tools/parity_reach.py) - git show origin/main:tools/parity.py > tools/parity.py - git show origin/main:tools/parity_reach.py > tools/parity_reach.py - status=0 - python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$? - git checkout -- tools/parity.py - if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi - cat .local/parity-selection.txt - if [ "$status" != 0 ]; then - echo "main's selection failed: the head's own selection runs" - own - exit - fi - names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true) - # The summary line says how many would run: a count that doesn't match the names read (a format change) - # must not let the job pass untested. - expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p') - count=$(printf '%s' "$names" | grep -c . || true) - if [ -z "$expected" ] || [ "$expected" != "$count" ]; then - echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs" - own - exit - fi - if [ "$count" = 0 ]; then - echo "Nothing to run." - exit 0 - fi - # shellcheck disable=SC2086 - python3 tools/parity.py $names --timeout 2400 + BASE_SHA: ${{ steps.merge.outputs.base }} + run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner - name: Logs of the failed harnesses if: failure() uses: actions/upload-artifact@v4 diff --git a/AGENTS.md b/AGENTS.md index 549fa5c7f..e1fa9e7fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles), -`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`. +`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build +reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main. ## Build and test fast @@ -66,7 +67,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link: python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job) python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them python3 tools/parity.py mob_ai sculk # named harnesses -python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread +python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end ``` @@ -156,7 +157,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end exactly as a LAN guest or a dedicated server's player does. This is what players run. Open `[server-thread]` issues list the gaps left. 2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks - the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path. + the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread. LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths when you touch player-facing gameplay: @@ -164,8 +165,8 @@ when you touch player-facing gameplay: ```sh cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame -python3 tools/lan_e2e.py --check # host in-frame, guest is a server client -python3 tools/lan_e2e.py --server-thread --check # host on its server thread (portals, levels, distance, lava need it) +python3 tools/lan_e2e.py --check # host on its server thread, guest is a server client +python3 tools/lan_e2e.py --in-frame --check # comparison (portals, levels, distance, lava need server-thread mode) python3 tools/parity.py dedicated_server server_entities server_player_damage ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21951c083..03f3bd97b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are welcome on [Discord](https://discord.gg/HcTeMZxe5E). -This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and -release them. Anything else (videos, Discord and other community material) lives elsewhere. +This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them. +The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere. **Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next ([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or diff --git a/Cargo.lock b/Cargo.lock index 4dc31ed01..bbbac4b2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1375,6 +1375,7 @@ dependencies = [ "horizon-core", "horizon-dualsense", "horizon-friends", + "horizon-hdr", "horizon-mods", "horizon-protocol", "horizon-server-files", @@ -1433,6 +1434,14 @@ dependencies = [ "spake2", ] +[[package]] +name = "horizon-hdr" +version = "0.1.0" +dependencies = [ + "ash", + "wgpu", +] + [[package]] name = "horizon-mod-sdk" version = "0.1.0" @@ -1444,12 +1453,14 @@ dependencies = [ name = "horizon-mods" version = "0.1.0" dependencies = [ + "horizon-shader-api", "log", "minisign-verify", "serde", "serde_json", "sha2", "toml", + "wasmparser", "wasmtime", "wit-component", "wit-parser", @@ -4154,6 +4165,28 @@ dependencies = [ "wit-parser", ] +[[package]] +name = "wast" +version = "254.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "febc59cde513fc8f39b8ffd487893723c555fdd024e2e3dc7d165181e6397ea0" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.254.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db96351e51a3a1bbe6812f029e1100b4d9a5e02161fb63104bd2271fc847e7e" +dependencies = [ + "wast", +] + [[package]] name = "wayland-backend" version = "0.3.17" @@ -5019,6 +5052,7 @@ dependencies = [ "wasm-encoder", "wasm-metadata", "wasmparser", + "wat", "wit-parser", ] diff --git a/Cargo.toml b/Cargo.toml index e2948ede6..ac1a19865 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] -members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] +members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] resolver = "2" [workspace.package] diff --git a/ROADMAP.md b/ROADMAP.md index a04eca2ce..9b82f4fac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates `cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable. -More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md) +More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod) @@ -160,6 +160,66 @@ Translatable components resolved through the selected language for items, contai +#### Cooldowns drawn on items + +After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla. + +
Technical + +Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763). + +More: [#1933](https://github.com/ludovic111/horizon/issues/1933) + +
+ +#### Chat settings and team prefixes + +The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat. + +
Technical + +Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce. + +More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841) + +
+ +#### A pointing hand over buttons + +The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. + +
Technical + +26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347). + +More: [#1897](https://github.com/ludovic111/horizon/issues/1897) + +
+ +#### Ctrl+Backspace and scroll bars in menus + +Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. + +
Technical + +`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)). + +More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931) + +
+ +#### Chickens flap while they fall + +Chickens should flap their wings while they fall, not stand still in the air. + +
Technical + +Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706). + +More: [#1892](https://github.com/ludovic111/horizon/issues/1892) + +
+ ### Rendering #### Shader packs, written again from scratch @@ -250,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck, Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page. -More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md) +More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance) @@ -540,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
Technical -A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off. +A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off. + +More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
@@ -876,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
Technical -`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark ` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711). +`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark ` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711). + +More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
@@ -902,56 +966,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla. -#### Cooldowns drawn on items - -After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla. - -
Technical - -Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763). - -
- -#### Chat settings and team prefixes - -The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat. - -
Technical - -Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce. - -
- -#### A pointing hand over buttons - -The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. - -
Technical - -26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347). - -
- -#### Ctrl+Backspace and scroll bars in menus - -Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. - -
Technical - -`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)). - -
- -#### Chickens flap while they fall - -Chickens should flap their wings while they fall, not stand still in the air. - -
Technical - -Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706). - -
- ## Recently shipped - **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 63a1f7da3..e83ccd840 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1 `crates/horizon-theme/icons/pixel/*.svg` are icons from [Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT -license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). +license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on +the same grid. diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs index b44d1f668..d2366c5e8 100644 --- a/crates/cargo-horizon-mod/src/check.rs +++ b/crates/cargo-horizon-mod/src/check.rs @@ -47,6 +47,7 @@ impl Report { "api": self.facts.api, "side": self.facts.side, "capabilities": self.facts.capabilities, + "api_level": self.facts.api_level, "game": self.facts.game, "requires": requires, "sha256": self.sha256, @@ -70,6 +71,8 @@ impl Report { if let Some(side) = f.side { let side = serde_json::to_value(side).unwrap_or_default(); out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?")); + } else if self.kind == Kind::Shader { + out += &format!(", shader API {}", f.api); } else if !f.api.is_empty() { out += &format!(", format {}", f.api); } @@ -85,6 +88,9 @@ impl Report { ")\n {} bytes, SHA-256 {}, {signed}\n", self.size, self.sha256 ); + if let Some(level) = f.api_level { + out += &format!(" needs mod API level {level} or newer\n"); + } for c in &f.capabilities { out += &format!(" may {}\n", c.describe()); } @@ -114,6 +120,7 @@ pub fn kind_of(path: &Path) -> Option { "hmod" => Some(Kind::Mod), "hpack" => Some(Kind::Modpack), "zip" => Some(Kind::ResourcePack), + "hshader" => Some(Kind::Shader), _ => None, } } @@ -255,6 +262,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { format!("{}: {text}", if error { "error" } else { "feedback" }) } Output::Command { line } => format!("command: /{line}"), + Output::QuietCommands { lines, .. } => { + format!("{} quiet command(s)", lines.len()) + } Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")), Output::SetBlocks { dimension, blocks, .. @@ -317,7 +327,7 @@ mod tests { .unwrap(); assert!(e.contains("signature"), "{e}"); let e = check(&pack, Kind::Shader, None, None).err().unwrap(); - assert!(e.contains("shader packs open"), "{e}"); + assert!(e.contains("horizon-shaders.toml"), "{e}"); assert!(check(&pack, Kind::Mod, None, None).is_err()); } } diff --git a/crates/horizon-assets/src/item_model.rs b/crates/horizon-assets/src/item_model.rs index 5999805eb..d567bc6ac 100644 --- a/crates/horizon-assets/src/item_model.rs +++ b/crates/horizon-assets/src/item_model.rs @@ -1031,19 +1031,22 @@ pub struct ItemModels { } impl ItemModels { - pub fn components(&self, stack: &StackView) -> Components { - let mut out = self - .prototypes - .get(&stack.item) - .cloned() - .unwrap_or_default(); + /// The stack's components: its item's defaults with the stack's patch on top. A stack + /// with no patch (most stacks a screen draws) borrows the defaults instead of copying them. + pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> { + static NONE: Components = BTreeMap::new(); + let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE); + if stack.patch.is_empty() { + return std::borrow::Cow::Borrowed(prototype); + } + let mut out = prototype.clone(); for (key, value) in &stack.patch { match value { Some(v) => out.insert(key.clone(), v.clone()), None => out.remove(key), }; } - out + std::borrow::Cow::Owned(out) } /// `ItemModelResolver.updateForTopItem`. pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem { diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index d7c6d812b..f1011c534 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -12,6 +12,7 @@ horizon-assets = { path = "../horizon-assets" } horizon-core = { path = "../horizon-core" } # The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority). horizon-threads = { path = "../horizon-threads" } +horizon-hdr = { path = "../horizon-hdr" } horizon-world = { path = "../horizon-world" } horizon-protocol = { path = "../horizon-protocol" } # server.properties, eula.txt and the user lists, shared with horizon-server. diff --git a/crates/horizon-client/examples/probe/creative_mining_probe.rs b/crates/horizon-client/examples/probe/creative_mining_probe.rs new file mode 100644 index 000000000..897e4bf81 --- /dev/null +++ b/crates/horizon-client/examples/probe/creative_mining_probe.rs @@ -0,0 +1,48 @@ +use horizon_client::{ + Result, + item_data::StackData, + items::{ItemStack, Items}, +}; +use horizon_world::nbt::Tag; +use serde_json::Value; +use std::{ + fs::File, + io::{BufRead, BufReader, BufWriter, Write}, + path::Path, +}; + +pub fn main() -> Result<()> { + let args: Vec<_> = crate::args().collect(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3"); + let items = Items::catalog(&root)?; + let mut out = BufWriter::new(File::create(&args[2])?); + for line in BufReader::new(File::open(&args[1])?).lines() { + let row: Value = serde_json::from_str(&line?)?; + let mut stack = ItemStack::new( + row["item"].as_str().unwrap(), + row["count"].as_u64().unwrap() as u32, + ); + match row["tool"].as_str().unwrap() { + "default" => {} + mode => { + let mut data = StackData::default(); + let tool = if mode == "implicit" { + Tag::compound([] as [(&str, Tag); 0]) + } else { + Tag::compound([( + "can_destroy_blocks_in_creative", + Tag::Byte(i8::from(mode == "allow")), + )]) + }; + data.set_other("minecraft:tool", Some(tool)); + stack.extra = Some(Box::new(data)); + } + } + writeln!( + out, + "{}", + items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap()) + )?; + } + Ok(()) +} diff --git a/crates/horizon-client/examples/probe/cushion_placement_probe.rs b/crates/horizon-client/examples/probe/cushion_placement_probe.rs new file mode 100644 index 000000000..45fbdbd72 --- /dev/null +++ b/crates/horizon-client/examples/probe/cushion_placement_probe.rs @@ -0,0 +1,89 @@ +use horizon_client::{ + Result, + cushion::{self, World}, +}; +use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape}; +use serde_json::Value; +use std::{ + collections::HashMap, + fs::File, + io::{BufRead, BufReader, BufWriter, Write}, +}; + +struct Cell { + outline: VoxelShape, + collision: VoxelShape, + suffocating: bool, +} +struct Fixture(HashMap<[i32; 3], Cell>); +impl World for Fixture { + fn outline_bounds(&self, p: [i32; 3]) -> Option { + self.0 + .get(&p)? + .outline + .bounds() + .map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))) + } + fn collision_shapes(&self, _: Aabb) -> Vec { + self.0 + .iter() + .map(|(p, c)| { + c.collision + .moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)) + }) + .collect() + } + fn suffocating(&self, p: [i32; 3]) -> bool { + self.0.get(&p).is_some_and(|c| c.suffocating) + } +} +fn shape(value: &Value) -> Result { + let mut shape = VoxelShape::empty(); + for b in value.as_array().unwrap() { + let f = |i: usize| b[i].as_f64().unwrap(); + shape = shape.join( + &VoxelShape::from_box(Aabb::new( + Vec3::new(f(0), f(1), f(2)), + Vec3::new(f(3), f(4), f(5)), + ))?, + ShapeOp::OR, + )?; + } + Ok(shape) +} +pub fn main() -> Result<()> { + let args: Vec<_> = crate::args().collect(); + let mut out = BufWriter::new(File::create(&args[2])?); + for line in BufReader::new(File::open(&args[1])?).lines() { + let row: Value = serde_json::from_str(&line?)?; + let mut world = Fixture(HashMap::new()); + for c in row["cells"].as_array().unwrap() { + let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32); + world.0.insert( + p, + Cell { + outline: shape(&c[3])?, + collision: shape(&c[4])?, + suffocating: c[5].as_bool().unwrap(), + }, + ); + } + let p = &row["pos"]; + let (x, y, z) = ( + p[0].as_f64().unwrap(), + p[1].as_f64().unwrap(), + p[2].as_f64().unwrap(), + ); + let bounds = Aabb::new( + Vec3::new(x - 0.5, y, z - 0.5), + Vec3::new(x + 0.5, y + 0.25, z + 0.5), + ); + writeln!( + out, + "{} {}", + cushion::can_place(&world, bounds), + cushion::survives(&world, bounds) + )?; + } + Ok(()) +} diff --git a/crates/horizon-client/examples/probe/entity_anim_fixture.rs b/crates/horizon-client/examples/probe/entity_anim_fixture.rs index 0a2e2951f..4aceb9f6f 100644 --- a/crates/horizon-client/examples/probe/entity_anim_fixture.rs +++ b/crates/horizon-client/examples/probe/entity_anim_fixture.rs @@ -9,7 +9,7 @@ use horizon_client::mesher::Vertex; use horizon_client::mobs::{ Mob, MobData, MobTypes, Part, Species, allay, anim::{self, Poses, State}, - aquatic, body_anim as b_anim, equine, guardian, + aquatic, bat, body_anim as b_anim, equine, guardian, keyframes::AnimationState, render, shulker, sniffer, species, tame, }; @@ -108,6 +108,22 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) { let flag = |k: &str| case[k].as_bool().unwrap_or(false); let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32; match species { + Species::Bat => { + mob.extra.flag = flag("isResting"); + mob.anim.states[0].started = case["flyAnimationState"].as_f64().map(|v| v as f32); + mob.anim.states[1].started = case["restAnimationState"].as_f64().map(|v| v as f32); + if let Some(ticks) = case["@batTicks"].as_array() { + for (i, flags) in ticks.iter().enumerate() { + mob.age = i as u32 + 1; + bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0); + if flag("@batInFrame") { + bat::tick(mob); + } else { + bat::setup_animation_states(mob); + } + } + } + } Species::Sheep => mob.extra.anim_ticks = num("eatTick") as i32, Species::Guardian | Species::ElderGuardian => { mob.anim.v[guardian::TAIL] = num("tailAnimation"); @@ -377,7 +393,7 @@ pub fn main() -> horizon_client::Result<()> { mob.extra.age = -24000; } mob.age = f("ageInTicks") as u32; - let state = State { + let mut state = State { pos: f("walkAnimationPos"), speed: f("walkAnimationSpeed"), age: f("ageInTicks"), @@ -389,6 +405,20 @@ pub fn main() -> horizon_client::Result<()> { }; let mut poses = Poses::new(&model); entity_state(&mut mob, def.species, case); + if let Some(ticks) = case["@walkTicks"].as_array() { + for speed in ticks { + mob.previous_limb_speed = mob.limb_speed; + mob.limb_speed += (speed.as_f64().unwrap() as f32 - mob.limb_speed) * 0.4; + mob.limb_position += mob.limb_speed; + } + if !case["@alive"].as_bool().unwrap() { + mob.health = 0.0; + } + let extracted = anim::state(&mob, f("@partial")); + state.pos = extracted.pos; + state.speed = extracted.speed; + } + let flag = |k: &str| case[k].as_bool().unwrap_or(false); let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32; let started = |k: &str| AnimationState { @@ -456,14 +486,6 @@ pub fn main() -> horizon_client::Result<()> { roll_amount: num("rollAmount"), }, ), - Species::Bat => b_anim::bat( - p, - s, - flag("isResting"), - &started("flyAnimationState"), - &started("restAnimationState"), - &animations, - ), Species::Armadillo => b_anim::armadillo( p, s, @@ -590,7 +612,11 @@ pub fn main() -> horizon_client::Result<()> { } _ => anim::setup(&mob, s, p, &animations), } - out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer))); + let mut result = emit(&model, &mut poses, case, layer); + if case["@walkTicks"].is_array() { + result["walk"] = json!([state.pos, state.speed]); + } + out.push_str(&format!("{result}\n")); } fs::write(&args[2], out)?; Ok(()) diff --git a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs index 86ef275d9..3f47ba787 100644 --- a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs +++ b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs @@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), }; for e in parts { let (name, level) = e.rsplit_once(':').unwrap_or((e, "1")); diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs index e80088fd0..6b4c3d7dd 100644 --- a/crates/horizon-client/examples/probe/main.rs +++ b/crates/horizon-client/examples/probe/main.rs @@ -27,7 +27,9 @@ mod chat_screen_fixture; mod cloud_fixture; mod command_parse_fixture; mod container_screen_probe; +mod creative_mining_probe; mod creative_search_fixture; +mod cushion_placement_probe; mod emitter_particles_probe; mod enchanting_fixture; mod entity_anim_fixture; @@ -83,6 +85,7 @@ mod sound_engine_fixture; mod sound_event_fixture; mod spawn_rules_fixture; mod sprint_probe; +mod squid_client_probe; mod subtitle_fixture; mod tab_list_fixture; mod team_rules_fixture; @@ -107,6 +110,9 @@ probes! { block_transform_fixture, breaking_fixture, camera_probe, + squid_client_probe, + creative_mining_probe, + cushion_placement_probe, cauldron_fixture, chat_screen_fixture, cloud_fixture, diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 49f56c250..5b6c26d2f 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -49,6 +49,7 @@ struct Scenario { name: String, ticks: i32, time: i64, + difficulty: u8, rain: bool, thunder: bool, origin: [i32; 2], @@ -85,11 +86,13 @@ fn parse(text: &str) -> Vec { "scenario" => { s.name = f[1].to_owned(); s.time = 6000; + s.difficulty = 2; for kv in &f[2..] { let (k, v) = kv.split_once('=').unwrap_or((kv, "")); match k { "ticks" => s.ticks = v.parse().unwrap(), "time" => s.time = v.parse().unwrap(), + "difficulty" => s.difficulty = v.parse().unwrap(), "rain" => s.rain = true, "thunder" => s.thunder = true, "levelseed" => {} @@ -954,6 +957,10 @@ pub fn main() -> horizon_client::Result<()> { for flag in &spec.flags { match flag.as_str() { "persistent" => m.persistent = true, + "named" => { + m.custom_name = Some("Peaceful regression".into()); + m.persistent = true; + } "ridden" => { m.extra.head = Some("minecraft:red_harness".into()); m.rider = Some(horizon_client::mobs::equine::Rider::default()); @@ -1050,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> { velocity: Vec3::ZERO, yaw: 0.0, hero: false, + effects: Vec::new(), uuid: PLAYER_UUID, allies: Default::default(), remote: false, @@ -1090,7 +1098,7 @@ pub fn main() -> horizon_client::Result<()> { block_tags: &items.block_tags, biomes: &biomes, mob_griefing: true, - difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0), + difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0), end: false, world_seed: 0, // FlatLevelSource.getSeaLevel. diff --git a/crates/horizon-client/examples/probe/scene_motion_probe.rs b/crates/horizon-client/examples/probe/scene_motion_probe.rs index 21977eeab..6153e098b 100644 --- a/crates/horizon-client/examples/probe/scene_motion_probe.rs +++ b/crates/horizon-client/examples/probe/scene_motion_probe.rs @@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> { .iter() .filter_map(|e| match e { MotionEvent::Fall(e) => Some(e.trace()), + // The oracle's player wears no armor; the game applies this one. + MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None, MotionEvent::Health(e) => Some(e.trace()), MotionEvent::Snowflake { position, velocity } => Some(format!( "snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}", diff --git a/crates/horizon-client/examples/probe/section_mesh_fixture.rs b/crates/horizon-client/examples/probe/section_mesh_fixture.rs index e212a5268..89624b78c 100644 --- a/crates/horizon-client/examples/probe/section_mesh_fixture.rs +++ b/crates/horizon-client/examples/probe/section_mesh_fixture.rs @@ -7,7 +7,7 @@ use horizon_client::{ biome::{BiomeInfo, Biomes, GrassModifier}, blocks::BlockFacts, chunk::{Chunk, Light, MIN_Y, index}, - mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex}, + mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex}, models, }; use horizon_core::biome::BiomeZoom; @@ -161,7 +161,15 @@ fn scene( .iter() .map(|v| { let ([x, y, z], [u, w]) = (v.pos, v.uv()); - json!([x, y, z, u, w, argb(v.color), light_coords(v.light)]) + json!([ + x, + y, + z, + u, + w, + argb(v.color | EMISSION_BITS), + light_coords(v.light) + ]) }) .collect(); quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices})); diff --git a/crates/horizon-client/examples/probe/squid_client_probe.rs b/crates/horizon-client/examples/probe/squid_client_probe.rs new file mode 100644 index 000000000..f9f9c9b2c --- /dev/null +++ b/crates/horizon-client/examples/probe/squid_client_probe.rs @@ -0,0 +1,51 @@ +use horizon_client::{ + Result, + mobs::{Mob, MobTypes, aquatic}, +}; +use horizon_core::geometry::Vec3; +use serde_json::Value; +use std::{ + fs::File, + io::{BufRead, BufReader, BufWriter, Write}, + path::Path, + sync::Arc, +}; + +pub fn main() -> Result<()> { + let args: Vec<_> = crate::args().collect(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3"); + let types = MobTypes::load(&root)?; + let mut out = BufWriter::new(File::create(&args[2])?); + for line in BufReader::new(File::open(&args[1])?).lines() { + let row: Value = serde_json::from_str(&line?)?; + let kind = Arc::new( + types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(), + ); + let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0); + mob.fluids.in_water = row["water"].as_bool().unwrap(); + mob.velocity = Vec3::new( + row["x"].as_f64().unwrap(), + row["y"].as_f64().unwrap(), + row["z"].as_f64().unwrap(), + ); + mob.body_yaw = row["body"].as_f64().unwrap() as f32; + for (i, value) in row["v"].as_array().unwrap().iter().enumerate() { + mob.anim.v[i] = value.as_f64().unwrap() as f32; + } + if row["reset"].as_bool().unwrap() { + aquatic::squid_cycle_event(&mut mob); + } + let velocity = mob.velocity; + aquatic::squid_client_step(&mut mob); + assert_eq!( + velocity, mob.velocity, + "a remote animation must preserve packet velocity" + ); + write!(out, "{:08x}", mob.body_yaw.to_bits())?; + for value in &mob.anim.v[..6] { + write!(out, " {:08x}", value.to_bits())?; + } + writeln!(out)?; + } + Ok(()) +} diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs index 48e8f16f4..28b28544b 100644 --- a/crates/horizon-client/src/animate_tick.rs +++ b/crates/horizon-client/src/animate_tick.rs @@ -58,7 +58,10 @@ enum Animate { BubbleColumn { drag: bool, }, - Fire, + /// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true). + Fire { + soul: bool, + }, /// An active sculk sensor. SculkSensor, /// Potent sulfur that is not dry. @@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { "bubble_column" => Animate::BubbleColumn { drag: prop("drag").as_deref() == Some("true"), }, - "fire" | "soul_fire" => Animate::Fire, + "fire" => Animate::Fire { soul: false }, + "soul_fire" => Animate::Fire { soul: true }, "dried_ghast" => Animate::DriedGhast { waterlogged: prop("waterlogged").as_deref() == Some("true"), }, @@ -190,6 +194,8 @@ pub struct AnimateTables { power_transmitter: Vec, /// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil). dried_ghast_sounds: Vec, + /// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged). + burnable: Vec, /// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`). ambient: Vec>, } @@ -220,6 +226,21 @@ impl AnimateTables { }) .collect() }; + let behaviour: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?; + let ignite_odds = behaviour["fire"]["igniteOdds"].as_object(); + let burnable = (0..registry.len() as u32) + .map(|id| { + // FireBlock.getIgniteOdds: a waterlogged state never burns. + registry.state(BlockStateId(id)).is_some_and(|s| { + s.properties.get("waterlogged").map(String::as_str) != Some("true") + && ignite_odds + .and_then(|m| m.get(s.name.as_str())) + .and_then(serde_json::Value::as_i64) + .is_some_and(|odds| odds > 0) + }) + }) + .collect(); let mut ambient = vec![Vec::new(); biomes.list.len()]; for (name, &id) in &biomes.index { let path = root.join(format!( @@ -248,6 +269,7 @@ impl AnimateTables { power_provider: by_state("minecraft:enchantment_power_provider"), power_transmitter: by_state("minecraft:enchantment_power_transmitter"), dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"), + burnable, ambient, }) } @@ -1103,7 +1125,7 @@ fn block_animate_tick( } } } - Animate::Fire => { + Animate::Fire { soul } => { let r = engine.random(); if next_int(r, 24) == 0 { let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3); @@ -1115,6 +1137,55 @@ fn block_animate_tick( pitch, ); } + // BaseFireBlock.animateTick: three large smoke above the fire, or + // two at each burnable neighbour's face when nothing burnable or + // sturdy supports it (SoulFireBlock.canBurn is always true). + let can_burn = |p: [i32; 3]| { + soul || tables + .burnable + .get(ctx.state(p).0 as usize) + .copied() + .unwrap_or(false) + }; + let below = [pos[0], pos[1] - 1, pos[2]]; + let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0; + if !can_burn(below) && !sturdy { + // (neighbour, coordinate axis, face at the axis' high end): the + // west, east, north, south and above faces, in vanilla order. + for (at, axis, high) in [ + ([pos[0] - 1, pos[1], pos[2]], 0usize, false), + ([pos[0] + 1, pos[1], pos[2]], 0, true), + ([pos[0], pos[1], pos[2] - 1], 2, false), + ([pos[0], pos[1], pos[2] + 1], 2, true), + ([pos[0], pos[1] + 1, pos[2]], 1, true), + ] { + if !can_burn(at) { + continue; + } + for _ in 0..2 { + let r = engine.random(); + let d = [r.next_double(), r.next_double(), r.next_double()]; + let mut p = [fx + d[0], fy + d[1], fz + d[2]]; + // The face coordinate hugs the edge: `* 0.1F` (float). + p[axis] = if high { + f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32) + } else { + f64::from(pos[axis]) + d[axis] * f64::from(0.1f32) + }; + add(engine, T::LargeSmoke, p, [0.0; 3]); + } + } + } else { + for _ in 0..3 { + let r = engine.random(); + let p = [ + fx + r.next_double(), + fy + r.next_double() * 0.5 + 0.5, + fz + r.next_double(), + ]; + add(engine, T::LargeSmoke, p, [0.0; 3]); + } + } } } } @@ -1157,13 +1228,19 @@ pub fn campfire_particles<'a>( if r.next_float() < 0.11 { let count = next_int(r, 2) + 2; for _ in 0..count { - make_campfire_smoke(engine, pos, signal); + make_campfire_smoke(engine, pos, signal, false); } } } } -/// `CampfireBlock.makeParticles` (always visible, overriding the limiter). -pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: bool) { +/// `CampfireBlock.makeParticles` (always visible, overriding the limiter); +/// `smoking` adds the plain smoke puff at the logs. +pub fn make_campfire_smoke( + engine: &mut ParticleEngine, + pos: [i32; 3], + signal: bool, + smoking: bool, +) { let r = engine.random(); let sign = |r: &mut LegacyRandom| if r.next_boolean() { 1.0 } else { -1.0 }; let dx = r.next_double() / 3.0; @@ -1177,6 +1254,17 @@ pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: b T::CampfireCosySmoke }; engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]); + if smoking { + let r = engine.random(); + let dx = r.next_double() / 4.0 * sign(r); + let dz = r.next_double() / 4.0 * sign(r); + let at = [ + f64::from(pos[0]) + 0.5 + dx, + f64::from(pos[1]) + 0.4, + f64::from(pos[2]) + 0.5 + dz, + ]; + engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]); + } } #[cfg(test)] diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 30b501011..f3c9c63bd 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -156,7 +156,11 @@ pub struct ArrowState { /// `pickupItemStack`: what picking the arrow up gives. pub pickup_item: crate::items::ItemStack, pub no_physics: bool, + /// `AbstractArrow.getPierceLevel`. pub pierce: u8, + /// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone + /// through (cleared when it sticks in a block). + pub pierced: Vec, /// `Entity.remainingFireTicks`. pub fire_ticks: i32, pub fall_distance: f64, @@ -199,6 +203,7 @@ impl ArrowState { pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1), no_physics: false, pierce: 0, + pierced: Vec::new(), fire_ticks: 0, fall_distance: 0.0, fluids: EntityFluidInteraction::new([WATER, LAVA]), @@ -362,6 +367,29 @@ fn bounds(at: Vec3) -> Aabb { Vec3::new(at.x + w, at.y + f64::from(HEIGHT), at.z + w), ) } +/// The bounding box of an arrow, or a thrown trident, at `at`. +pub fn arrow_bounds(at: Vec3) -> Aabb { + bounds(at) +} +/// The blocks an arrow that moved from `from` to `to` was inside +/// (`Entity.checkInsideBlocks`: the sweep of its box, 16 steps at most), for +/// the blocks that react to it (`entityInside`). +pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec { + let deflated = bounds(to).deflate(Vec3::new( + f64::from(1.0e-5f32), + f64::from(1.0e-5f32), + f64::from(1.0e-5f32), + )); + let mut inside = Vec::new(); + let _ = sweep_intersected_blocks(from, to, deflated, 1_000_000, |p, iteration| { + if iteration >= 16 { + return false; + } + inside.push(p); + true + }); + inside +} /// `Projectile.lerpRotation`. fn lerp_rotation(mut old: f32, new: f32) -> f32 { while new - old < -180.0 { @@ -431,10 +459,19 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity match outcome { EntityHit::Hurt => { ticker.hit_sound(); - ticker.st.removed = true; + // A piercing arrow flies on (`getPierceLevel() <= 0` discards). + if ticker.st.pierce == 0 { + ticker.st.removed = true; + } } EntityHit::Refused => ticker.turn_back(), - EntityHit::Deflected => {} + EntityHit::Deflected => { + // The deflection comes before `onHitEntity` adds the entity to + // `piercingIgnoreEntityIds`. + if ticker.st.pierce > 0 { + ticker.st.pierced.pop(); + } + } } ticker.finish(); p.stuck = st.in_ground; @@ -639,7 +676,8 @@ impl Ticker<'_, '_, '_, '_> { self.st.life = 0; } /// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the - /// block (piercing is not modelled). + /// block. A piercing arrow that hits an entity here goes on from it next + /// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick. fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit { let block = self.scene.clip(from, to); let end = block.map_or(to, |b| b.0); @@ -653,6 +691,10 @@ impl Ticker<'_, '_, '_, '_> { if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) { continue; } + // canHitEntity: not an entity a piercing arrow has gone through. + if self.st.pierced.contains(who) { + continue; + } if let Some(at) = self.entity_hit(b, from, end, margin) { // Sorted by the distance to the entity's position (the // stable sort keeps the first of equals). @@ -667,6 +709,15 @@ impl Ticker<'_, '_, '_, '_> { self.p.position = next; self.apply_effects_from_blocks(from, next); if let Some((who, at, _)) = best { + // AbstractArrow.onHitEntity: past pierce level + 1 entities the + // arrow is discarded before it hurts the next one. + if self.st.pierce > 0 { + if self.st.pierced.len() > usize::from(self.st.pierce) { + self.st.removed = true; + return Hit::None; + } + self.st.pierced.push(who); + } return Hit::Entity { who, at }; } let Some((at, pos, face)) = block else { @@ -752,6 +803,7 @@ impl Ticker<'_, '_, '_, '_> { self.st.shake = 7; self.p.set_critical(false); self.st.pierce = 0; + self.st.pierced.clear(); } /// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`. fn may_interact(&self) -> bool { diff --git a/crates/horizon-client/src/benchmark.rs b/crates/horizon-client/src/benchmark.rs index bcb94589c..12e38e78a 100644 --- a/crates/horizon-client/src/benchmark.rs +++ b/crates/horizon-client/src/benchmark.rs @@ -323,10 +323,15 @@ const QUIET_MESHES_PER_S: f64 = 100.0; /// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are /// drawn once no stage has held work for half a second: blocks changing after the load (fluids /// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet. +/// +/// Right after a teleport (and at spawn) the game reports nothing queued and steady counts for over +/// a second before it starts loading: the counts must change once, or stay full for 5 s, and the +/// resident chunks reach [`view_chunks`], before "steady" means loaded. fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result { let deadline = Instant::now() + LOAD_LIMIT; let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now()); let mut previous: Option<(Instant, f64)> = None; + let (mut first, mut moved, began) = (None, false, Instant::now()); while Instant::now() < deadline { let s = control.status(); let now = Instant::now(); @@ -339,6 +344,19 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result 0 + && resident >= min_chunks + && now - began > Duration::from_secs(5)); + if min_chunks > 0 && (!moved || resident < min_chunks) { + (last, changed, previous, meshing) = (None, now, None, now); + thread::sleep(Duration::from_millis(50)); + continue; + } if last != Some(counts) { (last, changed) = (Some(counts), now); } @@ -370,6 +388,12 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result u64 { + (0.8 * std::f64::consts::PI * (distance * distance) as f64) as u64 +} + /// Vanilla's yaw facing from `start` to `end`. fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 { (-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees() @@ -420,6 +444,13 @@ mod tests { assert!(scenes("nether").is_none()); } + #[test] + fn view_chunks_matches_perf_bench() { + assert_eq!(view_chunks(12), 361); + assert_eq!(view_chunks(32), 2573); + assert_eq!(view_chunks(0), 0); + } + #[test] fn yaw_matches_vanilla() { // South (+Z) is 0, west (-X) 90, east (+X) -90. diff --git a/crates/horizon-client/src/bin/horizon-ctl/main.rs b/crates/horizon-client/src/bin/horizon-ctl/main.rs index 402808e81..33ea1f870 100644 --- a/crates/horizon-client/src/bin/horizon-ctl/main.rs +++ b/crates/horizon-client/src/bin/horizon-ctl/main.rs @@ -610,12 +610,17 @@ mod tests { #[test] fn unreachable_games_fail_with_status_3() { - let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None); + let home = + std::env::temp_dir().join(format!("horizon-ctl-unreachable-{}", std::process::id())); + std::fs::create_dir_all(&home).unwrap(); + let mut s = Session::new(home.join("nobody.sock"), None); + s.home = home.clone(); let f = s.run(&["state".to_owned()]).unwrap_err(); assert_eq!(f.status, UNREACHABLE); assert_eq!(f.reply["kind"], "connection"); let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err(); assert_eq!(f.status, USAGE); + std::fs::remove_dir_all(home).unwrap(); } #[test] diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index 63ad004d4..c8c15e44e 100644 --- a/crates/horizon-client/src/biome.rs +++ b/crates/horizon-client/src/biome.rs @@ -28,6 +28,9 @@ pub struct BiomeInfo { pub spawn_costs: HashMap, /// The `gameplay/creature_world_gen_spawn_probability` attribute. pub creature_probability: f32, + /// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks, + /// mushroom fields). + pub increased_fire_burnout: bool, pub name: String, pub temperature: f32, pub downfall: f32, @@ -128,6 +131,9 @@ impl Biomes { attributes["minecraft:gameplay/creature_world_gen_spawn_probability"] .as_f64() .unwrap_or(0.1) as f32, + increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"] + .as_bool() + .unwrap_or(false), temperature, downfall, precipitation: v["has_precipitation"].as_bool().unwrap_or(true), diff --git a/crates/horizon-client/src/block_entity_anim.rs b/crates/horizon-client/src/block_entity_anim.rs index d91748e73..fd91051fa 100644 --- a/crates/horizon-client/src/block_entity_anim.rs +++ b/crates/horizon-client/src/block_entity_anim.rs @@ -648,6 +648,61 @@ impl Conduit { } } +/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ConduitCheck { + /// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`. + pub toggled: Option, + /// The frame blocks counted, while the conduit is active: `applyEffects` + /// and `updateAndAttackTarget` follow (the attack needs 42 or more). + pub frames: Option, +} + +/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks. +#[derive(Clone, Debug, Default)] +pub struct ConduitServer { + shape: Conduit, + /// `destroyTarget`: the mob id of the enemy being struck. + pub target: Option, + next_ambient_sound: i64, +} + +impl ConduitServer { + /// The `gameTime % 40` part of `serverTick`: the shape is checked again. + pub fn check( + &mut self, + pos: [i32; 3], + game_time: i64, + is_water: impl Fn([i32; 3]) -> bool, + is_frame: impl Fn([i32; 3]) -> bool, + ) -> Option { + if game_time % 40 != 0 { + return None; + } + let active = self.shape.update_shape(pos, &is_water, &is_frame); + let toggled = (active != self.shape.is_active).then_some(active); + self.shape.is_active = active; + Some(ConduitCheck { + toggled, + frames: active.then_some(self.shape.effect_blocks.len()), + }) + } + /// The sounds of an active conduit at the end of `serverTick`: whether + /// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every + /// 60 to 99 ticks) play. + pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) { + if !self.shape.is_active { + return (false, false); + } + let short = game_time > self.next_ambient_sound; + if short { + self.next_ambient_sound = + game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0)); + } + (game_time % 80 == 0, short) + } +} + /// `SkullBlockEntity` animation (client ticker of dragon and piglin heads). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Skull { @@ -832,6 +887,8 @@ pub struct BlockEntityAnims { pub gateways: FxHashMap<[i32; 3], EndGateway>, /// The integrated server's copies of the gateways (`portalTick`). pub gateway_servers: FxHashMap<[i32; 3], EndGateway>, + /// The integrated server's copies of the conduits (`serverTick`). + pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>, /// The client level's random (conduit particles). pub random: LegacyRandom, /// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]). @@ -854,6 +911,7 @@ impl Default for BlockEntityAnims { pots: FxHashMap::default(), gateways: FxHashMap::default(), gateway_servers: FxHashMap::default(), + conduit_servers: FxHashMap::default(), random: LegacyRandom::new(0x6c6576656c), kinds: Vec::new(), present: Vec::new(), @@ -909,6 +967,7 @@ impl BlockEntityAnims { self.pots.retain(|p, _| keep(*p)); self.gateways.retain(|p, _| keep(*p)); self.gateway_servers.retain(|p, _| keep(*p)); + self.conduit_servers.retain(|p, _| keep(*p)); } /// `getOpenNess(partial)` of the chest or ender chest at `pos`. pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 { diff --git a/crates/horizon-client/src/block_entity_nbt.rs b/crates/horizon-client/src/block_entity_nbt.rs index 54d230eb4..a4c02ba3b 100644 --- a/crates/horizon-client/src/block_entity_nbt.rs +++ b/crates/horizon-client/src/block_entity_nbt.rs @@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option { } "minecraft:trial_spawner" => { // `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional - // and defaulted by vanilla; they are kept as loaded. + // and defaulted by vanilla; they are kept as loaded. No `spawn_data` is + // added: an absent one loads as Optional.empty and is not stored, and + // an empty entity would keep the spawner from ever picking a mob. if let Tag::Compound(c) = tag { for (k, v) in c { let key = k.to_string_lossy(); @@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option { } } } - if !f.out.contains_key(&"spawn_data".into()) { - f.put( - "spawn_data", - Tag::compound([("entity", Tag::Compound(Compound::new()))]), - ); - } } "minecraft:vault" => { // `VaultConfig.DEFAULT` (when the config is absent): the trial key and the @@ -827,6 +823,21 @@ mod tests { canonical(&snbt(s)).expect("known type") } + #[test] + fn a_trial_spawner_without_spawn_data_saves_none() { + let fresh = canon( + r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#, + ); + assert_eq!(fresh.get("spawn_data"), None); + let picked = canon( + r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#, + ); + assert_eq!( + picked.get("spawn_data"), + Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#)) + ); + } + #[test] fn every_type_has_defaults() { for id in TYPES.iter().chain(NEWEST.iter()) { diff --git a/crates/horizon-client/src/block_entity_render.rs b/crates/horizon-client/src/block_entity_render.rs index 10c5e2509..28a876d80 100644 --- a/crates/horizon-client/src/block_entity_render.rs +++ b/crates/horizon-client/src/block_entity_render.rs @@ -558,12 +558,11 @@ fn frac(x: f32) -> f32 { /// `StringSplitter.splitLines(...).get(0)` for plain text: the first line, /// wrapped at the last space that fits or mid-word when none does. fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String { - let chars: Vec = line.chars().collect(); let mut width = 0.0; let mut last_space = None; - for (i, &c) in chars.iter().enumerate() { + for (i, c) in line.char_indices() { if c == '\n' { - return chars[..i].iter().collect(); + return line[..i].to_owned(); } if c == ' ' { last_space = Some(i); @@ -572,9 +571,11 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri if width > max_width { let end = match last_space { Some(s) if s > 0 => s, - _ => i.max(1), + _ if i > 0 => i, + // The first char alone is too wide: take that one char. + _ => c.len_utf8(), }; - return chars[..end].iter().collect(); + return line[..end].to_owned(); } } line.to_owned() @@ -2098,4 +2099,13 @@ mod tests { assert_eq!(out[1].pos, [2.0, 0.0, 0.0]); assert_eq!(out[2].uv, [0.5, 0.5]); } + + #[test] + fn first_line_wraps_on_bytes_without_splitting_a_char() { + let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 }; + assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab"); + assert_eq!(first_line("abcdef", 13.0, &advance), "ab"); + assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}"); + assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab"); + } } diff --git a/crates/horizon-client/src/bundle.rs b/crates/horizon-client/src/bundle.rs index 8658b5815..42473729f 100644 --- a/crates/horizon-client/src/bundle.rs +++ b/crates/horizon-client/src/bundle.rs @@ -166,6 +166,38 @@ impl Contents { Some(stack) } + /// The stacks inside, in display order (the first is the next out). + pub fn items(&self) -> &[ItemStack] { + &self.items + } + + /// `getNumberOfItemsToShow`: a full grid shows 11 and a `+N`, so the last + /// row keeps its fourth cell for the count. + pub fn shown_count(&self) -> usize { + let count = self.items.len(); + let available: usize = if count > 12 { 11 } else { 12 }; + let on_last_row = count % 4; + let empty = if on_last_row == 0 { 0 } else { 4 - on_last_row }; + count.min(available.saturating_sub(empty)) + } + + /// The items the tooltip shows, and how many sit beyond them. + pub fn shown(&self) -> (&[ItemStack], u32) { + let n = self.shown_count(); + let hidden = self.items[n..].iter().map(|s| s.count).sum(); + (&self.items[..n], hidden) + } + + /// The `Fraction` weight as the float the progress bar reads. + pub fn weight_fraction(&self) -> f32 { + self.weight.n as f32 / self.weight.d as f32 + } + + /// `Fraction.ONE`: a full bundle draws the full bar and says so. + pub fn is_full(&self) -> bool { + self.weight.n >= self.weight.d + } + /// `toImmutable`, written back into the bundle's component. pub fn store(self, bundle: &mut ItemStack) { bundle.data_mut().bundle_contents = self.items; @@ -190,3 +222,174 @@ mod tests { assert_eq!(over.how_many(Weight::new(1, 64)), 0); } } + +// ------------------------------------------------------------- the tooltip + +/// `ClientBundleTooltip`'s geometry, relative to the tooltip box's top-left: +/// the 96 pixel column of a 4 wide slot grid and the progress bar under it. +#[derive(Clone, Debug, PartialEq)] +pub struct TooltipLayout { + /// The component's own width (96) and height, the text's excluded. + pub width: f32, + pub height: f32, + /// Each slot's top-left and the stack in it, in reading order. + pub slots: Vec<([f32; 2], ItemStack)>, + /// The `+N` of the items past the grid: its top-left and the count. + pub overflow: Option<([f32; 2], u32)>, + /// The progress bar's top-left, its fill's width out of 94, and whether + /// the weight fills it (the full sprite). + pub bar: [f32; 2], + pub fill: f32, + pub full: bool, + /// `item.minecraft.bundle.empty` / `.full`, centred over the bar. + pub label: Option<&'static str>, + /// An empty bundle's description, wrapped at `width` from here. + pub empty_description: Option<[f32; 2]>, +} + +/// The tooltip of a bundle's contents (`ClientBundleTooltip`), or None when +/// the stack is not a bundle. `x`/`y` are the box's top-left, `box_width` the +/// tooltip's width, which the component is centred in. +pub fn tooltip_layout( + items: &Items, + stack: &ItemStack, + x: f32, + y: f32, + box_width: f32, +) -> Option { + let contents = Contents::of(items, stack)?; + let offset = ((box_width - 96.0) / 2.0).floor(); + if contents.items().is_empty() { + // The description takes the top; the bar follows it. + let description_height = 9.0 * 2.0; + return Some(TooltipLayout { + width: 96.0, + height: description_height + 13.0 + 8.0, + slots: Vec::new(), + overflow: None, + bar: [x + offset, y + description_height + 4.0], + fill: 0.0, + full: false, + label: Some("item.minecraft.bundle.empty"), + empty_description: Some([x + offset, y]), + }); + } + let (shown, hidden) = contents.shown(); + // `gridSizeY` counts `slotCount()` (at most twelve), not the slots the + // overflow count leaves room for. + let rows = contents.items().len().min(12).div_ceil(4).max(1); + let grid_height = rows as f32 * 24.0; + let x_start = x + offset + 96.0; + let y_start = y + grid_height; + let mut slots = Vec::with_capacity(shown.len()); + let mut overflow = None; + // Rows fill bottom-up and columns right-to-left, as vanilla draws them. + let mut slot = 0; + for row in 1..=rows { + for column in 1..=4 { + let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0]; + if hidden > 0 && row == 1 && column == 1 { + overflow = Some((at, hidden)); + } else if let Some(stack) = shown.get(slot) { + slots.push((at, stack.clone())); + slot += 1; + } + } + } + let weight = contents.weight_fraction(); + Some(TooltipLayout { + width: 96.0, + height: grid_height + 13.0 + 8.0, + slots, + overflow, + bar: [x + offset, y + grid_height + 4.0], + // `Mth.mulAndTruncate(weight, 94)`, kept within the bar. + fill: (weight * 94.0).trunc().clamp(0.0, 94.0), + full: contents.is_full(), + label: if contents.is_full() { + Some("item.minecraft.bundle.full") + } else { + None + }, + empty_description: None, + }) +} + +#[cfg(test)] +mod tooltip_tests { + use super::*; + + /// The prepared resources, when they are on this machine: the tooltip + /// asks the item registry whether a stack is a bundle. + fn items() -> Option { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("items.json").exists() { + return None; + } + let assets = crate::assets::Assets::load(&root).ok()?; + let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?; + Items::load(&root, assets.registry, &facts).ok() + } + + fn bundle_with(counts: &[(&str, u32)]) -> ItemStack { + let mut bundle = ItemStack::new("minecraft:bundle", 1); + bundle.data_mut().bundle_contents = counts + .iter() + .map(|(item, n)| ItemStack::new(item, *n)) + .collect(); + bundle + } + + #[test] + fn a_grid_shows_twelve_stacks_and_counts_the_rest() { + let Some(items) = items() else { return }; + let twelve: Vec<(&str, u32)> = (0..12).map(|_| ("minecraft:stone", 1)).collect(); + let layout = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 96.0).unwrap(); + assert_eq!(layout.slots.len(), 12); + assert_eq!(layout.overflow, None); + assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0); + // Rows fill bottom-up and columns right-to-left: the first item is in + // the bottom-right cell, the twelfth in the top-left. + assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]); + assert_eq!(&*layout.slots[0].1.item, "minecraft:stone"); + assert_eq!(layout.slots[11].0, [0.0, 0.0]); + // A wider tooltip centres the 96 pixel column in it. + let centred = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 120.0).unwrap(); + assert_eq!(centred.slots[0].0[0], 12.0 + 96.0 - 24.0); + // Thirteen stacks: `getNumberOfItemsToShow` leaves the last row room + // for the count, so 8 slots show and `+6` (four stones and two dirt) + // takes the first cell. + let mut counts = twelve; + counts.push(("minecraft:dirt", 2)); + let layout = tooltip_layout(&items, &bundle_with(&counts), 0.0, 0.0, 96.0).unwrap(); + assert_eq!(layout.slots.len(), 8); + assert_eq!(layout.overflow, Some(([96.0 - 24.0, 48.0], 6))); + } + + #[test] + fn the_bar_fills_with_the_weight_and_an_empty_bundle_explains_itself() { + let Some(items) = items() else { return }; + let layout = tooltip_layout(&items, &bundle_with(&[]), 4.0, 8.0, 96.0).unwrap(); + assert!(layout.slots.is_empty()); + assert_eq!(layout.fill, 0.0); + assert_eq!(layout.label, Some("item.minecraft.bundle.empty")); + assert_eq!(layout.empty_description, Some([4.0, 8.0])); + assert_eq!(layout.bar, [4.0, 8.0 + 18.0 + 4.0]); + // A bundle holding one 64 stack is full: the whole bar, the full label. + let layout = tooltip_layout( + &items, + &bundle_with(&[("minecraft:stone", 64)]), + 0.0, + 0.0, + 96.0, + ) + .unwrap(); + assert!(layout.full); + assert_eq!(layout.fill, 94.0); + assert_eq!(layout.label, Some("item.minecraft.bundle.full")); + // Something that is not a bundle has no component. + let dirt = ItemStack::new("minecraft:dirt", 1); + assert!(tooltip_layout(&items, &dirt, 0.0, 0.0, 96.0).is_none()); + } +} diff --git a/crates/horizon-client/src/chat_tests.rs b/crates/horizon-client/src/chat_tests.rs index 6b058c0fb..beec6cd81 100644 --- a/crates/horizon-client/src/chat_tests.rs +++ b/crates/horizon-client/src/chat_tests.rs @@ -436,3 +436,34 @@ fn the_chat_option_filters_the_lines_both_ways() { chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full)); assert_eq!(shown(&chat), ["client", "server", "player"]); } + +/// The in-frame LAN host's chat: a guest's line is a player message, so the +/// Chat option's player-message permission hides it at Commands Only and +/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays +/// at Commands Only. +#[test] +fn a_guests_line_follows_the_player_message_permission() { + let font = font(); + let shown = |visibility, source| { + let mut chat = Chat::default(); + chat.set_abilities(&font, ChatAbilities::new(visibility)); + chat.add_message(&font, Text::literal("line"), None, source, 0, false); + chat.messages.len() + }; + for (visibility, player, system) in [ + (ChatVisibility::Full, 1, 1), + (ChatVisibility::System, 0, 1), + (ChatVisibility::Hidden, 0, 0), + ] { + assert_eq!( + shown(visibility, ChatSource::Player), + player, + "{visibility:?}" + ); + assert_eq!( + shown(visibility, ChatSource::Server), + system, + "{visibility:?}" + ); + } +} diff --git a/crates/horizon-client/src/chunk.rs b/crates/horizon-client/src/chunk.rs index 41330df5f..7cd375343 100644 --- a/crates/horizon-client/src/chunk.rs +++ b/crates/horizon-client/src/chunk.rs @@ -24,6 +24,20 @@ pub fn block_strategy() -> Strategy { Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed)) } +/// Frees `columns` (`count` of them) on a short-lived thread of its own +/// when there are many: a teleport or a dimension change drops a few +/// thousand columns at once, and freeing their sections took the frame +/// 15-20 ms. A few are freed here. +pub fn free_columns(columns: T, count: usize) { + if count < 64 { + return; + } + // Should the thread not start, the closure drops `columns` here. + let _ = std::thread::Builder::new() + .name("Chunk freeing".into()) + .spawn(move || drop(columns)); +} + /// Sizes the block states' global palette for a registry of `states`. pub fn set_block_states(states: usize) { let bits = horizon_core::paletted::ceil_log2(states).max(16); @@ -147,6 +161,9 @@ pub struct Chunk { /// Structure starts reaching into this chunk (`structures.References`): /// structure id and the start's chunk. pub structure_refs: Vec<(Arc, (i32, i32))>, + /// The starts generation made in this chunk (`structures.starts`), saved + /// with the state their placement has reached by then. + pub structure_starts: Vec>, } impl Chunk { pub fn empty(x: i32, z: i32) -> Self { @@ -164,6 +181,7 @@ impl Chunk { block_entities: Vec::new(), block_names: Vec::new(), structure_refs: Vec::new(), + structure_starts: Vec::new(), postprocess: Vec::new(), saved: None, entities: Vec::new(), diff --git a/crates/horizon-client/src/clipboard.rs b/crates/horizon-client/src/clipboard.rs index c21460cdb..76872091c 100644 --- a/crates/horizon-client/src/clipboard.rs +++ b/crates/horizon-client/src/clipboard.rs @@ -346,6 +346,11 @@ mod tests { assert_eq!(filter_text(s, true), "abc\nde\u{80}"); assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§"); assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo"); + // TextFieldHelper.getClipboardContents runs before insertText's filter. + assert_eq!( + filter_text(&helper_clipboard_contents("§aHello"), false), + "Hello" + ); } struct Field { diff --git a/crates/horizon-client/src/container_screen.rs b/crates/horizon-client/src/container_screen.rs index 2c490861a..77c7ad8aa 100644 --- a/crates/horizon-client/src/container_screen.rs +++ b/crates/horizon-client/src/container_screen.rs @@ -275,4 +275,30 @@ mod tests { Release::Click(Input::QuickMove) ); } + + /// A press whose release never arrives (the pointer left the window, or + /// the screen closed first) leaves `isQuickCrafting` set; the next screen + /// starts with its own state, as `AbstractContainerScreen` does, so the + /// reset the game runs on open/close makes clicks work again. + #[test] + fn a_lost_release_does_not_wedge_the_next_screen() { + let mut clicks = Clicks::default(); + let slot = Target::::Slot(3); + // Pressed with a stack carried: a quick craft drag starts, and its + // release is lost. + assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None); + assert!(clicks.quick_crafting().is_some()); + assert_eq!( + clicks.press(&slot, Button::Left, true, false, false), + None, + "the stuck drag swallows the next press" + ); + // A new screen: fresh state, so the press is a normal click again. + clicks = Clicks::default(); + assert!(clicks.quick_crafting().is_none()); + assert_eq!( + clicks.press(&slot, Button::Left, true, false, false), + Some(Input::Pickup) + ); + } } diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index d13c5e45b..8ca9af0ad 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -49,6 +49,17 @@ pub struct Furnace { /// rather than per item, and a save keeps it as the `RecipesUsed` compound. pub recipes_used: HashMap, } +/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey` +/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so +/// keeping both forms would write a key twice, and vanilla refuses such a furnace +/// ("Duplicate entry for key"). +pub fn recipe_used_key(id: &str) -> String { + if id.contains(':') { + id.to_owned() + } else { + format!("minecraft:{id}") + } +} /// A hopper's slots: the only plain container with five. pub const HOPPER_SLOTS: usize = 5; @@ -83,8 +94,68 @@ pub enum BlockEntity { Beehive(Box), /// `CommandBlockEntity` of the three command blocks (`crate::command_block`). CommandBlock(Box), + /// `CampfireBlockEntity` of campfires and soul campfires. + Campfire(Box), /// `SkullBlockEntity` of every skull and head block. Skull(Skull), + /// `DaylightDetectorBlockEntity`: no data, only the ticker + /// (`DaylightDetectorBlock.tickEntity`). + DaylightDetector, +} +/// `CampfireBlockEntity`: four cooking slots, each with its +/// `cookingProgress` and `cookingTime` (the recipe's). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Campfire { + pub items: [Option; 4], + pub progress: [i32; 4], + pub total: [i32; 4], +} +impl Campfire { + /// `placeFood`'s slot: the first empty one, given the item's + /// `campfire_cooking` time; it takes one item of `held`. + pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool { + let Some(slot) = self.items.iter().position(Option::is_none) else { + return false; + }; + self.total[slot] = cooking_time; + self.progress[slot] = 0; + self.items[slot] = Some(ItemStack { + count: 1, + ..held.clone() + }); + held.count = held.count.saturating_sub(1); + true + } + /// `cookTick` (lit): each item's progress rises by one; the slots whose + /// progress reached their cooking time give up their item (the caller + /// drops its result). + pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> { + let mut done = Vec::new(); + for i in 0..4 { + if self.items[i].is_none() { + continue; + } + self.progress[i] += 1; + if self.progress[i] >= self.total[i] + && let Some(item) = self.items[i].take() + { + done.push((i, item)); + } + } + done + } + /// `cooldownTick` (unlit): the progress falls by two, no lower than 0. + /// Whether anything changed (`setChanged`). + pub fn cooldown_tick(&mut self) -> bool { + let mut changed = false; + for i in 0..4 { + if self.progress[i] > 0 { + changed = true; + self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]); + } + } + changed + } } /// `SkullBlockEntity`: the player profile the head shows (kept as loaded: /// a compound, or a string when it names a player only), the note block @@ -295,6 +366,9 @@ impl BlockEntity { if is_skull(name) { return Some(Self::Skull(Skull::default())); } + if name == "minecraft:daylight_detector" { + return Some(Self::DaylightDetector); + } if name == "minecraft:beacon" { return Some(Self::Beacon(Box::new(Beacon { last_check_y: i32::MIN / 2, @@ -304,6 +378,9 @@ impl BlockEntity { if name == "minecraft:brewing_stand" { return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new()))); } + if name == "minecraft:campfire" || name == "minecraft:soul_campfire" { + return Some(Self::Campfire(Box::default())); + } let size = match name { "minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27, n if n.ends_with("copper_chest") => 27, @@ -341,6 +418,8 @@ impl BlockEntity { Self::Beehive(_) => "minecraft:beehive", Self::CommandBlock(_) => "minecraft:command_block", Self::Skull(_) => "minecraft:skull", + Self::DaylightDetector => "minecraft:daylight_detector", + Self::Campfire(_) => "minecraft:campfire", } } pub fn contents(&self) -> Vec { @@ -357,11 +436,13 @@ impl BlockEntity { | Self::Vault(_) | Self::Beehive(_) | Self::CommandBlock(_) - | Self::Skull(_) => Vec::new(), + | Self::Skull(_) + | Self::DaylightDetector => Vec::new(), Self::Lectern(l) => l.book.iter().cloned().collect(), Self::Sculk(_) => Vec::new(), Self::Banner(_) => Vec::new(), Self::Crafter(c) => c.items.iter().flatten().cloned().collect(), + Self::Campfire(c) => c.items.iter().flatten().cloned().collect(), } } pub fn slots(&self) -> &[Option] { @@ -375,14 +456,17 @@ impl BlockEntity { | Self::Lectern(_) | Self::Sculk(_) | Self::Banner(_) - | Self::Skull(_) => &[], + | Self::Skull(_) + | Self::DaylightDetector => &[], Self::Crafter(c) => &c.items, Self::CreakingHeart(_) | Self::Spawner(_) | Self::TrialSpawner(_) | Self::Vault(_) | Self::Beehive(_) - | Self::CommandBlock(_) => &[], + | Self::CommandBlock(_) + // Not a Container (only Clearable): hoppers don't reach in. + | Self::Campfire(_) => &[], } } pub fn slots_mut(&mut self) -> &mut [Option] { @@ -396,14 +480,16 @@ impl BlockEntity { | Self::Lectern(_) | Self::Sculk(_) | Self::Banner(_) - | Self::Skull(_) => &mut [], + | Self::Skull(_) + | Self::DaylightDetector => &mut [], Self::Crafter(c) => &mut c.items, Self::CreakingHeart(_) | Self::Spawner(_) | Self::TrialSpawner(_) | Self::Vault(_) | Self::Beehive(_) - | Self::CommandBlock(_) => &mut [], + | Self::CommandBlock(_) + | Self::Campfire(_) => &mut [], } } } @@ -638,7 +724,10 @@ impl Furnace { Some(out) => out.count += recipe.result.count, None => self.items[2] = Some(recipe.result.clone()), } - *self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1; + *self + .recipes_used + .entry(recipe_used_key(&recipe.id)) + .or_insert(0) += 1; if let Some(i) = self.items[0].as_mut() { i.count -= 1; if i.count == 0 { @@ -692,6 +781,41 @@ mod tests { use super::*; use horizon_core::random::{LegacyRandom, RandomSource}; + #[test] + fn campfire_slots_take_food_cook_it_and_cool_down() { + let mut c = Campfire::default(); + let mut beef = ItemStack::new("minecraft:beef", 2); + // placeFood: the first free slot, one item, its recipe's time. + assert!(c.place_food(&mut beef, 600)); + assert_eq!(beef.count, 1); + assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1)); + assert_eq!((c.progress[0], c.total[0]), (0, 600)); + let mut fish = ItemStack::new("minecraft:cod", 1); + assert!(c.place_food(&mut fish, 3)); + assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod")); + // cookTick: the cod is done on its third tick, the beef keeps cooking. + assert!(c.cook_tick().is_empty()); + assert!(c.cook_tick().is_empty()); + let done = c.cook_tick(); + assert_eq!(done.len(), 1); + assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod")); + assert!(c.items[1].is_none()); + assert_eq!(c.progress[0], 3); + // cooldownTick: two a tick, never below zero. + assert!(c.cooldown_tick()); + assert_eq!(c.progress[0], 1); + assert!(c.cooldown_tick()); + assert_eq!(c.progress[0], 0); + // Full: four slots, no fifth. + for _ in 0..3 { + let mut more = ItemStack::new("minecraft:potato", 1); + assert!(c.place_food(&mut more, 600)); + } + let mut fifth = ItemStack::new("minecraft:potato", 1); + assert!(!c.place_food(&mut fifth, 600)); + assert_eq!(fifth.count, 1); + } + /// A whole amount pays itself without a draw; a fraction is a chance of one more. #[test] fn create_experience_rounds_the_fraction_by_chance() { diff --git a/crates/horizon-client/src/crafting.rs b/crates/horizon-client/src/crafting.rs index 690383963..899ce90d9 100644 --- a/crates/horizon-client/src/crafting.rs +++ b/crates/horizon-client/src/crafting.rs @@ -1612,7 +1612,9 @@ impl Recipes { } /// The `experience` of the cooking recipe with this file name, and 0 for one this data /// pack does not have (a save from another pack can name it). + /// `id` with or without the `minecraft:` namespace (a furnace keeps the full id). pub fn cooking_experience(&self, id: &str) -> f32 { + let id = id.strip_prefix("minecraft:").unwrap_or(id); self.cooking .iter() .find(|c| c.id == id) diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index 249e40b11..1a227db65 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -1,9 +1,14 @@ //! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their //! contents as built by the game's own display generators //! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab. -use crate::{Result, items::ItemStack}; +use crate::{Result, items::ItemStack, ui::EditBox}; use serde_json::Value; -use std::{collections::HashMap, fs, path::Path}; +use std::{ + collections::HashMap, + fs, + path::Path, + sync::{Arc, Mutex}, +}; #[derive(Clone, Debug)] pub struct Tab { @@ -35,13 +40,22 @@ pub struct Creative { pub scroll: f32, /// The scrollbar is held (scrolling): dragging moves it. pub scrolling: bool, - pub search: String, - /// When the search box took the focus (`EditBox.setFocused`): its - /// cursor blinks from then. - pub search_focused: std::time::Instant, + /// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50. + pub search: EditBox, /// The search tab's stacks, in its display order. search_items: Vec, search_index: Vec, + /// ItemPickerMenu.items: what the grid shows, filled when the tab or the + /// search changes (selectTab, refreshSearchResults), not on every frame. + shown: Mutex>, +} +/// The grid's stacks for one tab, search text and language. +struct Shown { + tab: usize, + search: String, + /// A display name, so a language change refills the search results. + language: String, + items: Arc<[ItemStack]>, } /// Display names from the pinned en_us language file. #[derive(Default)] @@ -284,10 +298,16 @@ impl Creative { selected: 0, scroll: 0.0, scrolling: false, - search: String::new(), - search_focused: std::time::Instant::now(), + search: { + let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0); + search.bordered = false; + search.max_length = 50; + search.text_color = u32::MAX; + search + }, search_items, search_index, + shown: Mutex::new(None), } } /// Where a stack comes in the creative tabs (the search tab lists them @@ -300,18 +320,35 @@ impl Creative { .position(|s| s.same_item_same_components(stack)) .or_else(|| items.iter().position(|s| s.item == stack.item)) } + /// The tabs without their stacks: what the screen draws of them each frame. + pub fn tab_headers(&self) -> Vec { + self.tabs + .iter() + .map(|t| Tab { + id: t.id.clone(), + title: t.title.clone(), + icon: t.icon.clone(), + items: Vec::new(), + top: t.top, + column: t.column, + search: t.search, + inventory: t.inventory, + }) + .collect() + } pub fn tab(&self) -> &Tab { &self.tabs[self.selected.min(self.tabs.len() - 1)] } /// selectTab: a different tab empties the search box; the scrollbar goes to the top. pub fn select(&mut self, tab: usize) { if tab != self.selected { - self.search.clear(); + self.search.set_value(""); } self.selected = tab; self.scroll = 0.0; if self.tab().search { - self.search_focused = std::time::Instant::now(); + self.search.focused = true; + self.search.reset_blink(); } } /// The search tab's stacks. @@ -380,17 +417,44 @@ impl Creative { }) .collect() } - /// Items shown by the selected tab (the search tab filters by `search`). - pub fn items(&self, names: &Names) -> Vec { - let tab = self.tab(); - if tab.search { - return self - .search_results(&self.search, names) + /// Items shown by the selected tab (the search tab filters by `search`), + /// worked out again only when the tab, the search or the language changed. + pub fn items(&self, names: &Names) -> Arc<[ItemStack]> { + let tab = self.selected.min(self.tabs.len() - 1); + let search = if self.tab().search { + self.search.value.as_str() + } else { + "" + }; + let language = names.display_get("itemGroup.search").unwrap_or_default(); + let mut shown = self.shown.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(s) = shown.as_ref() + && s.tab == tab + && s.search == search + && s.language == language + { + return s.items.clone(); + } + let items: Arc<[ItemStack]> = if self.tab().search { + self.search_results(search, names) .into_iter() .map(|i| self.search_items[i].clone()) - .collect(); - } - tab.items.clone() + .collect() + } else { + self.tab().items.as_slice().into() + }; + *shown = Some(Shown { + tab, + search: search.to_owned(), + language, + items: items.clone(), + }); + items + } + /// Drop the shown items, so the next `items` works them out again: a + /// reload can rename items without changing the Search label. + pub fn forget_items(&self) { + *self.shown.lock().unwrap_or_else(|e| e.into_inner()) = None; } pub fn rows(&self, names: &Names) -> usize { self.items(names).len().div_ceil(9) @@ -754,13 +818,13 @@ mod tests { (1, false, true), (2, true, false), ] { - c.search = "anvil".into(); + c.search.set_value("anvil"); c.scroll = 0.5; c.select(selected); assert_eq!(c.selected, selected); assert_eq!(c.tab().inventory, inventory); assert_eq!(c.tab().search, search); - assert!(c.search.is_empty()); + assert!(c.search.value.is_empty()); assert_eq!(c.scroll, 0.0); } } @@ -780,12 +844,12 @@ mod tests { assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil"); let mut c = c; c.select(1); - c.search = "anvil".into(); + c.search.set_value("anvil"); c.scroll = 0.5; c.select(1); - assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0)); + assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0)); c.select(0); - assert_eq!(c.search, ""); + assert_eq!(c.search.value, ""); } #[test] @@ -899,4 +963,25 @@ mod tests { c.scroll = 1.0; assert_eq!(c.scroller_offset(), 95.0); } + + #[test] + fn search_edits_at_its_cursor() { + use winit::keyboard::KeyCode; + let mut c = creative(0); + c.select(1); + c.search.insert_text("hello world"); + for _ in 0..5 { + assert!(c.search.key(KeyCode::ArrowLeft, false, false)); + } + c.search.insert_text("X"); + assert_eq!(c.search.value, "hello Xworld"); + c.search.key(KeyCode::Home, false, true); + c.search.insert_text("Y"); + assert_eq!(c.search.value, "Yworld"); + // Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor. + c.search.key(KeyCode::ArrowLeft, true, false); + c.search.key(KeyCode::ArrowRight, false, false); + c.search.key(KeyCode::Backspace, false, false); + assert_eq!(c.search.value, "world"); + } } diff --git a/crates/horizon-client/src/cushion.rs b/crates/horizon-client/src/cushion.rs new file mode 100644 index 000000000..7610a4aab --- /dev/null +++ b/crates/horizon-client/src/cushion.rs @@ -0,0 +1,59 @@ +//! Cushion placement and support checks (`Cushion.canBePlacedAt`/`wouldSurviveAt`). +use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape}; + +pub trait World { + fn outline_bounds(&self, pos: [i32; 3]) -> Option; + fn collision_shapes(&self, bounds: Aabb) -> Vec; + fn suffocating(&self, pos: [i32; 3]) -> bool; +} + +pub fn blocks_in(a: Aabb) -> impl Iterator { + let (lo, hi) = (a.min_position(), a.max_position()); + let (x0, y0, z0) = ( + lo.x.floor() as i32, + lo.y.floor() as i32, + lo.z.floor() as i32, + ); + let (x1, y1, z1) = ( + hi.x.floor() as i32, + hi.y.floor() as i32, + hi.z.floor() as i32, + ); + (y0..=y1).flat_map(move |y| (z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| [x, y, z]))) +} + +pub fn survives(world: &impl World, bounds: Aabb) -> bool { + let (lo, hi) = (bounds.min_position(), bounds.max_position()); + let anchor = Aabb::new( + Vec3::new(lo.x, lo.y - 0.015625, lo.z), + Vec3::new(hi.x.next_down(), lo.y, hi.z.next_down()), + ); + // The extra eighth block expands the scan, not the intersection test. + let supported = blocks_in(anchor.expand_towards(Vec3::new(0.0, -0.125, 0.0))).any(|p| { + world + .outline_bounds(p) + .is_some_and(|shape| shape.intersects(anchor)) + }); + supported && !blocks_in(bounds.next_deflated()).all(|p| world.suffocating(p)) +} + +pub fn can_place(world: &impl World, bounds: Aabb) -> bool { + if !survives(world, bounds) { + return false; + } + let (lo, hi) = (bounds.min_position(), bounds.max_position()); + let slab = Aabb::new(lo, Vec3::new(hi.x, lo.y + 0.015625, hi.z)).next_deflated(); + let Ok(mut remaining) = VoxelShape::from_box(slab) else { + return false; + }; + for shape in world.collision_shapes(slab) { + let Ok(next) = remaining.join(&shape, ShapeOp::ONLY_FIRST) else { + return false; + }; + remaining = next; + if remaining.is_empty() { + return false; + } + } + true +} diff --git a/crates/horizon-client/src/debug_overlay.rs b/crates/horizon-client/src/debug_overlay.rs index 7140905a8..f55d4ee6e 100644 --- a/crates/horizon-client/src/debug_overlay.rs +++ b/crates/horizon-client/src/debug_overlay.rs @@ -221,18 +221,17 @@ pub fn scale( pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec { let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])]; for (lines, align_left) in [(columns[0], true), (columns[1], false)] { - let placed: Vec<(&String, i32, i32)> = lines + let placed: Vec<(&String, i32, i32, i32)> = lines .iter() .enumerate() .filter(|(_, l)| !l.is_empty()) .map(|(i, l)| { let w = font.width(l) as i32; let left = if align_left { 2 } else { width - 2 - w }; - (l, left, 2 + LINE_HEIGHT * i as i32) + (l, left, 2 + LINE_HEIGHT * i as i32, w) }) .collect(); - for (line, left, top) in &placed { - let w = font.width(line) as i32; + for (_, left, top, w) in &placed { out.push(fill( left - 1, top - 1, @@ -241,7 +240,7 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec BACKGROUND, )); } - for (line, left, top) in placed { + for (line, left, top, _) in placed { out.push(GuiDraw::Text { text: Text::literal(line.clone()), x: left, diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 65536d710..44f07dc82 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -10,6 +10,8 @@ use crate::{ use horizon_core::geometry::{Aabb, Vec3}; use horizon_world::block_collision::BlockCollisionRegistry; +mod fluids; + #[derive(Clone, Debug)] pub struct ItemEntity { pub health: i32, @@ -93,6 +95,15 @@ impl ItemEntity { self.health -= fire_damage as i32; } } + // CactusBlock.entityInside: `entity.hurt(cactus, 1.0)`. An item has no + // hurt cooldown, so it loses 1 health every tick it touches a cactus + // (5 health: gone in 5 ticks). Fire resistance does not help; + // `ItemStack.canBeHurtBy` only skips it for a damage_resistant stack. + if crate::motion::environment::cactus_contact(world, registry, self.bounds()) + && !items.resists_cactus(&self.stack) + { + self.health -= 1; + } ItemEnvironmentTick { alive: self.health > 0, lava_hurt, @@ -165,18 +176,16 @@ impl ItemEntity { facts: &BlockFacts, collision: &BlockCollisionRegistry<'_>, no_physics: bool, + fluids: &crate::motion::FluidResources, ) -> bool { self.ticks += 1; self.previous = self.position; if self.pickup_delay > 0 && self.pickup_delay != u32::MAX { self.pickup_delay -= 1; } - let eye = world.block( - self.position.x.floor() as i32, - (self.position.y + 0.1).floor() as i32, - self.position.z.floor() as i32, - ); - let in_water = facts.get(eye).fluid == Fluid::Water; + // Entity.baseTick pushes before ItemEntity applies buoyancy and moves. + let (water_height, lava_height) = self.update_fluids(world, fluids); + let in_water = water_height > f64::from(0.1f32); if in_water { // ItemEntity.setUnderwaterMovement self.velocity = Vec3::new( @@ -184,7 +193,7 @@ impl ItemEntity { self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 }, self.velocity.z * 0.99, ); - } else if facts.get(eye).fluid == Fluid::Lava { + } else if lava_height > f64::from(0.1f32) { self.velocity = Vec3::new( self.velocity.x * 0.95, self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 }, @@ -245,6 +254,8 @@ impl ItemEntity { } } self.age += 1; + // ItemEntity.tick refreshes immersion and pushes again after moving. + self.update_fluids(world, fluids); self.age < 6000 && self.position.y > -128.0 } /// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup @@ -383,8 +394,29 @@ pub struct ExperienceOrb { pub value: u32, pub age: u32, pub on_ground: bool, + /// `ExperienceOrb.count`: orbs merged into this one; each pickup gives + /// `value` once and takes one off. + pub count: u32, + /// `Entity.tickCount`, which paces `ExperienceOrb.scanForMerges`. + pub ticks: u32, } impl ExperienceOrb { + pub fn bounds(&self) -> Aabb { + let h = 0.25; + Aabb::new( + Vec3::new(self.position.x - h, self.position.y, self.position.z - h), + Vec3::new( + self.position.x + h, + self.position.y + 0.5, + self.position.z + h, + ), + ) + } + /// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed: + /// the same value and ids a multiple of 40 apart. + pub fn can_merge(&self, id: u32, value: u32) -> bool { + self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0 + } /// ExperienceOrb.getExperienceValue: the largest orb size not above `value`. pub fn split(value: u32) -> u32 { [2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1] @@ -414,7 +446,9 @@ impl ExperienceOrb { facts: &BlockFacts, collision: &BlockCollisionRegistry<'_>, player: Option<[f64; 3]>, + next_float: &mut dyn FnMut() -> f32, ) -> bool { + self.ticks += 1; self.previous = self.position; let block = world.block( self.position.x.floor() as i32, @@ -430,6 +464,14 @@ impl ExperienceOrb { } else { self.velocity.y -= 0.03; } + if facts.get(block).fluid == Fluid::Lava { + // ExperienceOrb.tick: lava throws the orb back out. + self.velocity = Vec3::new( + f64::from((next_float() - next_float()) * 0.2), + f64::from(0.2f32), + f64::from((next_float() - next_float()) * 0.2), + ); + } if let Some(p) = player { let d = [ (p[0] - self.position.x) / 8.0, @@ -447,15 +489,7 @@ impl ExperienceOrb { ); } } - let h = 0.25; - let bounds = Aabb::new( - Vec3::new(self.position.x - h, self.position.y, self.position.z - h), - Vec3::new( - self.position.x + h, - self.position.y + 0.5, - self.position.z + h, - ), - ); + let bounds = self.bounds(); let mut scene = SceneCollisions { world, binding: collision, @@ -492,6 +526,31 @@ impl ExperienceOrb { self.age < 6000 } } +/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`: +/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box, +/// adding their counts and keeping the smaller age. +pub fn merge_orbs(orbs: &mut Vec) { + let mut removed = vec![false; orbs.len()]; + for i in 0..orbs.len() { + if removed[i] || orbs[i].ticks % 20 != 1 { + continue; + } + let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5)); + for j in 0..orbs.len() { + if j != i + && !removed[j] + && orbs[j].can_merge(orbs[i].id, orbs[i].value) + && orbs[j].bounds().intersects(area) + { + orbs[i].count += orbs[j].count; + orbs[i].age = orbs[i].age.min(orbs[j].age); + removed[j] = true; + } + } + } + let mut removed = removed.into_iter(); + orbs.retain(|_| !removed.next().unwrap_or(false)); +} /// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel). #[derive(Clone, Copy, Debug, Default)] pub struct Experience { @@ -589,6 +648,57 @@ impl Painting { ) } } +/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when +/// the name is unknown. +pub fn dye_index(name: &str) -> u8 { + crate::mobs::render::DYE_NAMES + .iter() + .position(|n| *n == name) + .unwrap_or(0) as u8 +} +/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow +/// that seats a player. It sits on the top face of a block, at the clicked +/// height, and is coloured by its `DyeColor`. +#[derive(Clone, Debug)] +pub struct Cushion { + pub id: u32, + /// `Entity.position`: the bottom centre of the cushion. + pub position: Vec3, + /// `BlockAttachedEntity.pos` (the saved `block_pos`). + pub pos: [i32; 3], + pub yaw: f32, + /// `DyeColor` ordinal: white 0, orange 1, ..., black 15. + pub color: u8, + /// BlockAttachedEntity.ticksSinceLastCheck. + pub check_interval: u32, +} +impl Cushion { + /// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25. + pub fn bounds(&self) -> ([f64; 3], [f64; 3]) { + let p = self.position; + ( + [p.x - 0.5, p.y, p.z - 0.5], + [p.x + 0.5, p.y + 0.25, p.z + 0.5], + ) + } + /// The `DyeColor` name of the cushion. + pub fn color_name(&self) -> &'static str { + crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))] + } + /// `Cushion.getCushionItemStackWithData`: the matching coloured item. + pub fn item(&self) -> String { + format!("minecraft:{}_cushion", self.color_name()) + } + /// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment + /// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`. + pub fn seat(&self) -> Vec3 { + Vec3::new(self.position.x, self.position.y - 0.35, self.position.z) + } + /// `Entity.getDismountLocationForPassenger`: on top of the cushion. + pub fn dismount(&self) -> Vec3 { + Vec3::new(self.position.x, self.position.y + 0.25, self.position.z) + } +} /// An item frame or glow item frame (`ItemFrame`). #[derive(Clone, Debug)] pub struct ItemFrame { diff --git a/crates/horizon-client/src/entities/fluids.rs b/crates/horizon-client/src/entities/fluids.rs new file mode 100644 index 000000000..fffa0ae2b --- /dev/null +++ b/crates/horizon-client/src/entities/fluids.rs @@ -0,0 +1,216 @@ +//! Dropped-item immersion and currents through the shared vanilla fluid helpers. +use super::ItemEntity; +use crate::{ + motion::{FluidResources, FluidTags, LAVA, WATER}, + world::World, +}; +use horizon_core::{ + geometry::{Aabb, Direction, Vec3}, + position::BlockPos, +}; +use horizon_world::{ + entity_fluid::{ + CurrentTarget, EntityFluidInteraction, FluidInteractionWorld, FluidMembership, + FluidSections, FluidTagId, + }, + fluid::{FluidHeightWorld, FluidKind, FluidState}, + fluid_flow::{FluidFlowWorld, flow_at}, + registry::BlockStateId, +}; +type Error = std::convert::Infallible; +struct ItemFluidWorld<'a> { + item: &'a mut ItemEntity, + world: &'a World, + fluids: &'a FluidResources, +} +impl ItemEntity { + pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) { + let mut interaction = EntityFluidInteraction::new([WATER, LAVA]); + let mut scene = ItemFluidWorld { + item: self, + world, + fluids, + }; + if interaction.update(&mut scene, false, 4096).is_err() { + return (0.0, 0.0); + } + let water_height = interaction.fluid_height(&scene, WATER); + let lava_height = interaction.fluid_height(&scene, LAVA); + let _ = interaction.apply_current(&mut scene, WATER, 0.014); + let _ = interaction.apply_current( + &mut scene, + LAVA, + if world.fast_lava { + 0.007 + } else { + 0.0023333333333333335 + }, + ); + (water_height, lava_height) + } +} +impl FluidMembership for ItemFluidWorld<'_> { + fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool { + FluidTags.fluid_in_tag(kind, tag) + } +} +impl CurrentTarget for ItemFluidWorld<'_> { + type Error = Error; + fn is_player_class(&self) -> bool { + false + } + fn velocity(&mut self) -> Result { + Ok(self.item.velocity) + } + fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> { + let v = self.item.velocity; + let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z); + if delta.is_finite() && sum.is_finite() { + self.item.velocity = sum; + } + Ok(()) + } +} +impl FluidInteractionWorld for ItemFluidWorld<'_> { + type Error = Error; + type Chunk = (); + fn interaction_box(&mut self) -> Result, Error> { + Ok(Some( + self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)), + )) + } + fn loaded_chunk(&mut self, x: i32, z: i32) -> Result, Error> { + Ok(self.world.chunks.contains_key(&(x, z)).then_some(())) + } + fn chunk_sections(&mut self, _: ()) -> Result, Error> { + static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS]; + Ok(FluidSections { + minimum_section_y: crate::chunk::MIN_Y >> 4, + has_fluid: &SECTIONS, + }) + } + fn bounding_box_min_y(&self) -> f64 { + self.item.position.y + } + fn block_x(&self) -> i32 { + self.item.position.x.floor() as i32 + } + fn eye_y(&mut self) -> Result { + Ok(self.item.position.y + f64::from(0.2125f32)) + } + fn block_z(&self) -> i32 { + self.item.position.z.floor() as i32 + } + fn fluid_state(&mut self, p: BlockPos) -> Result { + Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z))) + } + fn fluid_height(&mut self, f: FluidState, p: BlockPos) -> Result { + f.height_at(self, [p.x, p.y, p.z]) + } + fn camera_height(&mut self, f: FluidState, p: BlockPos) -> Result { + f.camera_height_at(self, [p.x, p.y, p.z]) + } + fn flow(&mut self, f: FluidState, p: BlockPos) -> Result { + flow_at(self, f, p) + } +} +impl FluidHeightWorld for ItemFluidWorld<'_> { + type Error = Error; + fn fluid(&mut self, p: [i32; 3]) -> Result { + Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2]))) + } + fn sturdy_down(&mut self, p: [i32; 3]) -> Result { + let state = self.world.block(p[0], p[1], p[2]); + Ok(self.fluids.sturdy(state, Direction::Down)) + } +} +impl FluidFlowWorld for ItemFluidWorld<'_> { + type Error = Error; + type Block = BlockStateId; + fn fluid(&mut self, p: BlockPos) -> Result { + Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z))) + } + fn block(&mut self, p: BlockPos) -> Result { + Ok(self.world.block(p.x, p.y, p.z)) + } + fn blocks_fluid_flow(&self, s: BlockStateId) -> bool { + self.fluids.blocks_flow(s) + } + fn is_ice_class(&self, s: BlockStateId) -> bool { + self.fluids.ice(s) + } + fn is_face_sturdy( + &mut self, + s: BlockStateId, + _: BlockPos, + d: Direction, + ) -> Result { + Ok(self.fluids.sturdy(s, d)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap(); + let fluids = FluidResources::new(&assets, &facts).unwrap(); + let collision = crate::collisions::load(&root, assets.registry).unwrap(); + let mut world = World::test_scene(&assets, &render, 3).unwrap(); + let stone = assets.registry.default_state("minecraft:stone").unwrap(); + let water = assets.registry.default_state("minecraft:water").unwrap(); + let mut state = assets.registry.state(water).unwrap().clone(); + state.properties.insert("level".into(), "1".into()); + let flowing = assets.registry.resolve(&state).unwrap(); + for x in 19..=21 { + for z in 19..=21 { + world.set(x, 3, z, stone).unwrap(); + world.set(x, 4, z, water).unwrap(); + } + } + let at = Vec3::new(20.5, 4.0, 20.5); + let item = ItemEntity { + id: 1, + health: 5, + fire_ticks: 0, + stack: crate::items::ItemStack { + item: "minecraft:sugar_cane".into(), + count: 1, + damage: 0, + extra: None, + }, + thrower: None, + position: at, + previous: at, + velocity: Vec3::ZERO, + on_ground: false, + age: 0, + pickup_delay: 0, + bob_offset: 0.0, + ticks: 0, + }; + let mut still = item.clone(); + assert!(still.tick(&world, &facts, &collision, false, &fluids)); + assert_eq!(still.position.x, at.x); + assert_eq!(still.velocity.x, 0.0); + world.set(21, 4, 20, flowing).unwrap(); + let mut carried = item; + assert!(carried.tick(&world, &facts, &collision, false, &fluids)); + // First push is damped by buoyancy before motion, second follows drag. + assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12); + assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12); + for _ in 0..20 { + carried.tick(&world, &facts, &collision, false, &fluids); + } + assert!(carried.position.x > at.x + 0.5); + } +} diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 2e865dfb1..79e11719f 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -166,12 +166,23 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 { if d >= end { return 1.0; } return (d - start) / (end - start); } +// CRUMBLING_UV_BEGIN +fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, dimensions: vec2) -> vec2 { + let size = vec2(dimensions); + // Undo stage_light's 16-bit quantization before adding local texels. Otherwise + // different stages can fall on opposite sides of a nearest-sampling boundary. + let origin = round(packed_origin * size); + let extent = max(round(sprite_size * size), vec2(1.0)); + let local = min(floor(fract(uv) * extent), extent - vec2(1.0)); + return (origin + local + vec2(0.5)) / size; +} +// CRUMBLING_UV_END @fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4 { var uv = in.uv; if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; } if in.overlay == 11u || in.overlay == 12u { // Each crack's stage sprite origin comes in its light bytes (breaking::stage_light). - uv = in.wrap + fract(in.uv) * globals.crumbling_sprite.zw; + uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas)); } var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front); if in.overlay == 9u || in.overlay == 10u { diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..161ca12e9 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -6,7 +6,7 @@ //! survive a Horizon save. use crate::{ arrows::Pickup, - entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting}, + entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting}, mobs::{Mob, MobType, Who}, projectiles::{Projectile, ProjectileKind}, save::{ @@ -265,6 +265,19 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { Tag::Byte(i8::from(m.extra.flag2)), ); map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2)); + if id == "minecraft:piglin" { + // InventoryCarrier.writeInventoryToTag. + map.insert( + "Inventory".into(), + Tag::List( + m.extra + .inventory + .iter() + .map(crate::save::item_to_nbt) + .collect(), + ), + ); + } } "minecraft:horse" | "minecraft:donkey" @@ -357,6 +370,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { "minecraft:bat" => { map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag))); } + "minecraft:turtle" => { + map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag))); + if let Some(home) = m.extra.home { + map.insert("home_pos".into(), Tag::IntArray(home.to_vec())); + } + } "minecraft:creaking" => { // home_pos: the heart it is bound to. if let Some(home) = m.extra.home { @@ -482,7 +501,6 @@ 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()))); for (key, value) in [ - ("CanBreakDoors", Tag::Byte(0)), ("DrownedConversionTime", Tag::Int(-1)), ("InWaterTime", Tag::Int(-1)), ] { @@ -507,6 +525,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { } _ => {} } + // Zombie.addAdditionalSaveData: CanBreakDoors. + if crate::mobs::doors::is_zombie(m.species()) { + map.insert( + "CanBreakDoors".into(), + Tag::Byte(i8::from(m.extra.can_break_doors)), + ); + } crate::mobs::raider::write_nbt(m, map); crate::mobs::riders::write_nbt(m, map); // LivingEntity equipment: what the mob holds now. @@ -706,6 +731,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - ) { m.extra.flag2 = flag(tag, "IsImmuneToZombification"); m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32; + if m.species() == crate::mobs::Species::Piglin + && let Some(Tag::List(items)) = tag.get("Inventory") + { + // InventoryCarrier.readInventoryFromTag (eight slots). + m.extra.inventory = items + .iter() + .filter_map(crate::save::item_from_nbt) + .take(8) + .collect(); + } } if matches!( m.species(), @@ -847,6 +882,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - { crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]); } + if m.species() == crate::mobs::Species::Turtle { + // Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`. + m.extra.flag = flag(tag, "has_egg"); + m.extra.home = Some(match tag.get("home_pos") { + Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]], + _ => [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ], + }); + } if m.species() == crate::mobs::Species::Wither { m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32; } @@ -925,6 +972,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") { crate::mobs::finalize::set_baby(&mut m, true); } + // Zombie.readAdditionalSaveData: setCanBreakDoors. + if crate::mobs::doors::is_zombie(m.species()) { + crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors")); + } crate::mobs::raider::read_nbt(&mut m, tag); crate::mobs::riders::read_nbt(&mut m, tag); // A mob saved mid-death finishes dying; one saved dead is dropped. @@ -1043,18 +1094,18 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag { ("Health", Tag::Short(5)), ("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)), ("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)), - ("Count", Tag::Int(1)), + ("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)), ] { put(&mut tag, key, value); } tag } -/// One Horizon orb per counted orb (vanilla merges orbs of equal value). +/// One orb with its merged `Count`. pub fn orbs_from_nbt(tag: &Tag) -> Vec { let Some(position) = position(tag) else { return Vec::new(); }; - let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize; + let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32; let orb = ExperienceOrb { id: 0, position, @@ -1063,8 +1114,10 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec { value: number(tag.get("Value")).unwrap_or(1).max(1) as u32, age: number(tag.get("Age")).unwrap_or(0).max(0) as u32, on_ground: flag(tag, "OnGround"), + count, + ticks: 0, }; - vec![orb; count] + vec![orb] } /// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow; @@ -1313,6 +1366,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> { let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8; Some((pos, facing, text(tag.get("variant"))?)) } +/// Cushion.addAdditionalSaveData: the block it sits on and its colour. +pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { + let base = Base { + id: "minecraft:cushion", + uuid, + position: c.position, + motion: Vec3::ZERO, + rotation: [c.yaw, 0.0], + on_ground: false, + fall_distance: 0.0, + }; + let mut tag = base.tag(raw); + put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec())); + put(&mut tag, "color", string(c.color_name())); + tag +} +/// The position, block position, yaw and colour of a saved cushion. +pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option { + let position = position(tag)?; + let pos = match tag.get("block_pos") { + Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]], + _ => [ + position.x.floor() as i32, + position.y.floor() as i32, + position.z.floor() as i32, + ], + }; + let yaw = match tag.get("Rotation") { + Some(Tag::List(l)) => l + .first() + .and_then(|v| crate::save::float(Some(v))) + .unwrap_or(0.0) as f32, + _ => 0.0, + }; + let color = + crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into())); + Some(Cushion { + id, + position, + pos, + yaw, + color, + check_interval: 0, + }) +} + +#[cfg(test)] +mod cushion_tests { + use super::*; + + #[test] + fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() { + let cushion = Cushion { + id: 7, + position: Vec3::new(-1.5, 64.5, 3.5), + pos: [-2, 64, 3], + yaw: 270.0, + color: 14, + check_interval: 55, + }; + let raw = Tag::compound([("CustomName", string("Seat"))]); + let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw)); + assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat")); + let loaded = cushion_from_nbt(&tag, 8).unwrap(); + assert_eq!(loaded.id, 8); + assert_eq!(loaded.position, cushion.position); + assert_eq!(loaded.pos, cushion.pos); + assert_eq!(loaded.yaw, cushion.yaw); + assert_eq!(loaded.color_name(), "red"); + assert_eq!(loaded.check_interval, 0); + assert_eq!(loaded.bounds(), cushion.bounds()); + } +} /// ItemFrame.addAdditionalSaveData. pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { let (lo, hi) = f.bounds(); @@ -1840,7 +1966,7 @@ mod tests { assert_eq!(number(tag.get("Health")), Some(5)); } #[test] - fn orbs_expand_their_count() { + fn orbs_keep_their_count() { let o = ExperienceOrb { id: 0, position: Vec3::new(1.0, 2.0, 3.0), @@ -1849,12 +1975,14 @@ mod tests { value: 7, age: 3, on_ground: false, + count: 3, + ticks: 0, }; - let mut tag = orb_to_nbt(&o, [0; 4]); - put(&mut tag, "Count", Tag::Int(3)); + let tag = orb_to_nbt(&o, [0; 4]); let back = orbs_from_nbt(&tag); - assert_eq!(back.len(), 3); + assert_eq!(back.len(), 1); assert_eq!(back[0].value, 7); + assert_eq!(back[0].count, 3); } } @@ -1919,12 +2047,39 @@ fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) { } } const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"]; +/// `Salmon.Variant` names, by id. +const SALMON_SIZES: [&str; 3] = ["small", "medium", "large"]; +/// `TropicalFish.Pattern` names in enum order: the six small patterns, then +/// the six large ones (the order of `extra.variant`). +const TROPICAL_PATTERNS: [&str; 12] = [ + "kob", + "sunstreak", + "snooper", + "dasher", + "brinely", + "spotty", + "flopper", + "stripey", + "glitter", + "blockfish", + "betty", + "clayfish", +]; +/// `Bucketable.saveDefaultDataToBucketTag`, with the age an axolotl and a +/// tadpole add (`saveToBucketTag`). pub fn bucket_data(m: &Mob) -> Tag { - let mut data = Tag::compound([ - ("Age", Tag::Int(m.extra.age)), - ("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))), - ("Health", Tag::Float(m.health.to_bits())), - ]); + let mut data = Tag::compound([("Health", Tag::Float(m.health.to_bits()))]); + if matches!( + m.species(), + crate::mobs::Species::Axolotl | crate::mobs::Species::Tadpole + ) { + put(&mut data, "Age", Tag::Int(m.extra.age)); + put( + &mut data, + "AgeLocked", + Tag::Byte(i8::from(m.extra.age_locked)), + ); + } for (key, on) in [ ("NoAI", m.no_ai), ("Silent", m.silent), @@ -1954,6 +2109,29 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData { .unwrap_or(&"lucy"); data.set_other("minecraft:axolotl/variant", Some(string(variant))); } + // Salmon.saveToBucketTag: the size. + if m.species() == crate::mobs::Species::Salmon { + let size = SALMON_SIZES + .get(usize::from(m.extra.variant)) + .unwrap_or(&"medium"); + data.set_other("minecraft:salmon/size", Some(string(size))); + } + // TropicalFish.saveToBucketTag: the pattern and both colours. + if m.species() == crate::mobs::Species::TropicalFish { + let dye = |i: u8| crate::mobs::render::DYE_NAMES[usize::from(i) & 15]; + let pattern = TROPICAL_PATTERNS + .get(usize::from(m.extra.variant)) + .unwrap_or(&"kob"); + data.set_other("minecraft:tropical_fish/pattern", Some(string(pattern))); + data.set_other( + "minecraft:tropical_fish/base_color", + Some(string(dye(m.extra.variant2))), + ); + data.set_other( + "minecraft:tropical_fish/pattern_color", + Some(string(dye(m.extra.variant3))), + ); + } data } pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) { @@ -2004,6 +2182,39 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) { { m.extra.variant = id as u8; } + // Salmon.applyImplicitComponents: the size. + if m.species() == crate::mobs::Species::Salmon + && let Some(name) = text(components.other("minecraft:salmon/size")) + && let Some(id) = SALMON_SIZES + .iter() + .position(|&v| v == name.trim_start_matches("minecraft:")) + { + m.extra.variant = id as u8; + } + // TropicalFish.applyImplicitComponent: the pattern and both colours. + if m.species() == crate::mobs::Species::TropicalFish { + let name = |key| text(components.other(key)); + let dye = |key| { + name(key).and_then(|n| { + crate::mobs::render::DYE_NAMES + .iter() + .position(|&v| v == n.trim_start_matches("minecraft:")) + }) + }; + if let Some(id) = name("minecraft:tropical_fish/pattern").and_then(|n| { + TROPICAL_PATTERNS + .iter() + .position(|&v| v == n.trim_start_matches("minecraft:")) + }) { + m.extra.variant = id as u8; + } + if let Some(id) = dye("minecraft:tropical_fish/base_color") { + m.extra.variant2 = id as u8; + } + if let Some(id) = dye("minecraft:tropical_fish/pattern_color") { + m.extra.variant3 = id as u8; + } + } } #[cfg(test)] diff --git a/crates/horizon-client/src/extras.rs b/crates/horizon-client/src/extras.rs index b5b4fb165..d2f0b4e9d 100644 --- a/crates/horizon-client/src/extras.rs +++ b/crates/horizon-client/src/extras.rs @@ -378,12 +378,6 @@ impl Cps { pub enum Item { Fullbright, DynamicLights, - Minimap, - MinimapCorner, - MinimapSize, - MinimapShape, - MinimapZoom, - MinimapRotate, AutoReconnect, MouseTweaks, MouseTweaksWheel, @@ -402,13 +396,6 @@ pub fn rows() -> Vec> { Err("Lighting"), Ok(Fullbright), Ok(DynamicLights), - Err("Minimap"), - Ok(Minimap), - Ok(MinimapCorner), - Ok(MinimapSize), - Ok(MinimapShape), - Ok(MinimapZoom), - Ok(MinimapRotate), Err("Inventory"), Ok(MouseTweaks), Ok(MouseTweaksWheel), @@ -418,32 +405,11 @@ pub fn rows() -> Vec> { Ok(AutoReconnect), ] } -/// "1 block", "2 blocks", "½ block". -fn block_count(n: f32) -> String { - if n < 1.0 { - "½ block".into() - } else if n == 1.0 { - "1 block".into() - } else { - format!("{n} blocks") - } -} -fn next(all: &[T], current: T, back: bool) -> T { - let i = all.iter().position(|v| *v == current).unwrap_or(0); - let n = all.len(); - all[if back { (i + n - 1) % n } else { (i + 1) % n }] -} impl Item { pub fn caption(self) -> &'static str { match self { Item::Fullbright => "Fullbright", Item::DynamicLights => "Dynamic lights", - Item::MinimapCorner => "Corner", - Item::Minimap => "Minimap", - Item::MinimapSize => "Size", - Item::MinimapShape => "Shape", - Item::MinimapZoom => "Zoom", - Item::MinimapRotate => "Turn with the player", Item::AutoReconnect => "Auto-reconnect", Item::MouseTweaks => "Mouse Tweaks controls", Item::MouseTweaksWheel => "Scroll wheel moves items", @@ -458,10 +424,9 @@ impl Item { pub fn parent(self) -> Option { use Item::*; Some(match self { - MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap, MouseTweaksWheel => MouseTweaks, SortKey => Sort, - Fullbright | DynamicLights | Minimap | MouseTweaks | Sort | AutoReconnect | NoAi + Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi | AgentControl | DiscordPresence => { return None; } @@ -474,12 +439,6 @@ impl Item { Item::DynamicLights => { "Torches and other light sources held, dropped or on fire light the blocks around them." } - Item::MinimapCorner => "Overlays in one corner stack.", - Item::Minimap => "A map of the loaded terrain around you, in map colours.", - Item::MinimapSize => "How big the map is on screen.", - Item::MinimapShape => "Square or round.", - Item::MinimapZoom => "Blocks per pixel: smaller shows more detail, larger more land.", - Item::MinimapRotate => "Forward is up; off keeps north up.", Item::AutoReconnect => { "Rejoins a server after a lost connection or a restart, never after a kick or ban." } @@ -511,7 +470,7 @@ impl Item { use crate::graphics_style::Cost; match self { Item::Fullbright => Some(Cost::None), - Item::Minimap | Item::DynamicLights => Some(Cost::Low), + Item::DynamicLights => Some(Cost::Low), Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None), _ => None, } @@ -521,8 +480,6 @@ impl Item { Some(match self { Item::Fullbright => e.fullbright, Item::DynamicLights => e.dynamic_lights, - Item::Minimap => e.minimap, - Item::MinimapRotate => e.minimap_rotate, Item::AutoReconnect => e.auto_reconnect, Item::MouseTweaks => e.mouse_tweaks, Item::MouseTweaksWheel => e.mouse_tweaks_wheel, @@ -537,38 +494,22 @@ impl Item { pub fn value(self, e: &Extras) -> String { match self { Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(), - Item::MinimapCorner => e.minimap_corner.label().into(), - Item::MinimapSize => MINIMAP_SIZES - .iter() - .find(|s| s.0 == e.minimap_size) - .map_or("Medium", |s| s.1) - .into(), - Item::MinimapShape => if e.minimap_round { "Round" } else { "Square" }.into(), - Item::MinimapZoom => format!("{} per pixel", block_count(e.minimap_zoom)), other => match other.switch(e) { Some(true) => "On".into(), _ => "Off".into(), }, } } - /// A click (`back` for the right button). The sort key binds instead. - pub fn cycle(self, e: &mut Extras, back: bool) { + /// A click (`_back` for the right button: every item left is a switch, + /// so both flip it). The sort key binds instead. + pub fn cycle(self, e: &mut Extras, _back: bool) { match self { Item::Fullbright => e.fullbright = !e.fullbright, Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights, - Item::Minimap => e.minimap = !e.minimap, - Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate, Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect, Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks, Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel, Item::Sort => e.sort = !e.sort, - Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back), - Item::MinimapSize => { - let sizes: Vec = MINIMAP_SIZES.iter().map(|s| s.0).collect(); - e.minimap_size = next(&sizes, e.minimap_size, back); - } - Item::MinimapShape => e.minimap_round = !e.minimap_round, - Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back), Item::NoAi => e.no_ai = !e.no_ai, Item::AgentControl => e.agent_control = !e.agent_control, Item::DiscordPresence => e.discord_presence = !e.discord_presence, @@ -712,12 +653,9 @@ mod tests { #[test] fn items_cycle_through_their_values() { let mut e = Extras::default(); - Item::MinimapCorner.cycle(&mut e, true); - assert_eq!(e.minimap_corner, Corner::TopLeft); - Item::MinimapZoom.cycle(&mut e, true); - assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel"); - Item::MinimapSize.cycle(&mut e, false); - assert_eq!(Item::MinimapSize.value(&e), "Large"); + Item::Sort.cycle(&mut e, true); + assert_eq!(Item::Sort.value(&e), "On"); + // The minimap's old keys still parse, for the move to its mod. assert_eq!( Extras::parse("minimapSize:7\nminimapZoom:3").minimap_zoom, 1.0 diff --git a/crates/horizon-client/src/font.rs b/crates/horizon-client/src/font.rs index 1903cab7e..d0e8c8499 100644 --- a/crates/horizon-client/src/font.rs +++ b/crates/horizon-client/src/font.rs @@ -1201,6 +1201,31 @@ mod tests { assert!(!font.take_patches().is_empty()); } + /// A renderer rebuilt over the same font (the loading screen's handoff) + /// makes its atlas texture from the textures alone, so the glyphs it had + /// rasterized into the previous texture must be placed into the new one. + #[test] + fn a_rebuilt_atlas_gets_its_glyphs_again() { + let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x)); + let default = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#; + let root = root( + "rebuild", + &[ + ("assets/minecraft/font/default.json", default), + ("assets/minecraft/textures/font/a.png", &sheet), + ], + ); + let font = load(&root); + let _ = std::fs::remove_dir_all(&root); + let first = font.glyph('a'); + assert_eq!(font.take_patches().len(), 1); + font.reset_cache(); + // Nothing is left over: drawing the character again places its pixels. + assert!(font.take_patches().is_empty()); + assert_eq!(font.glyph('a').advance, first.advance); + assert_eq!(font.take_patches().len(), 1); + } + #[test] fn provider_filters_follow_the_font_options() { let sheet = png(8, 8, |x, _| x < 4); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 79f098a1b..f9edd98bb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -18,6 +18,8 @@ mod boats; mod books; mod bossbars; mod breezes; +mod campfire; +mod campfire_douse; mod cartography; mod chat; mod client_ambient; @@ -33,6 +35,7 @@ mod copper_golems; mod creaking; mod creatures; mod crossbow; +pub(crate) mod cushions; mod debug_keys; mod decorated_pot; mod dedicated; @@ -80,6 +83,7 @@ mod lan_mobs; mod lan_mount_menu; mod lan_particles; mod lan_pvp; +mod lan_quiet; mod lan_screen; mod lan_stations; mod lan_trading; @@ -131,6 +135,7 @@ mod pumpkin; mod raids; mod recipe_book; mod remote; +mod remote_chunks; mod remote_mob_data; mod remote_mob_use; mod remote_objects; @@ -181,6 +186,7 @@ mod win; mod wind; mod wither; mod world_loading; +mod zombie_reinforcements; mod zombie_villagers; use horizon_client::{ Result, @@ -532,6 +538,8 @@ struct GameLevel<'w> { events: &'w mut Vec, dimension: LevelDimension, darken: i32, + /// The environment of the level's dimension (`SUN_ANGLE`). + environment: &'w Environment, raining: bool, thundering: bool, player_box: Option<([f64; 3], [f64; 3])>, @@ -540,6 +548,8 @@ struct GameLevel<'w> { player_collision: Option, /// Minecart boxes (detector rails). cart_boxes: Vec<([f64; 3], [f64; 3])>, + /// Arrow and thrown trident boxes (wooden buttons). + arrow_boxes: Vec<([f64; 3], [f64; 3])>, /// The other entities that keep blocks from being placed into them. blockers: Blockers<'w>, biomes: &'w Biomes, @@ -552,6 +562,11 @@ struct GameLevel<'w> { loot: Option>, registry: &'static horizon_world::registry::BlockRegistry, rules: horizon_world::level::GameRules, + /// `Difficulty.getId()` (peaceful 0 .. hard 3). + difficulty: i32, + /// The feet of the players who keep fire spreading around them: the local + /// player and the LAN guests in this level, spectators aside. + fire_players: Vec<[f64; 3]>, /// Block loot random sequences (`ServerLevel.getRandomSequence`). rand_sequences: &'w mut horizon_client::random_sequences::RandomSequences, pending: &'w mut spill::Pending, @@ -561,6 +576,9 @@ struct GameLevel<'w> { random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections, /// Players with each chest open (a trapped chest's signal). lid_openers: &'w HashMap<(i32, i32, i32), i32>, + /// `Level.isClientSide`: a client's copy of the level (a guest of a LAN + /// world, a window on its server thread, a dedicated server's client). + client_side: bool, } struct PlayerCollision { y: f64, @@ -650,6 +668,9 @@ impl GameLevel<'_> { } } impl LevelAccess for GameLevel<'_> { + fn is_client_side(&self) -> bool { + self.client_side + } fn world_surface(&self, x: i32, z: i32) -> i32 { self.world.world_surface(x, z, |s| self.facts.is_air(s)) } @@ -715,6 +736,34 @@ impl LevelAccess for GameLevel<'_> { fn game_rules(&self) -> horizon_world::level::GameRules { self.rules } + fn difficulty(&self) -> i32 { + self.difficulty + } + /// `ServerLevel.canSpreadFireAround`: a player (not a spectator) closer + /// than the `fire_spread_radius_around_player` rule to the block's corner + /// (`ChunkMap.anyPlayerCloseEnoughTo`); -1 spreads everywhere. + fn can_spread_fire_around(&self, pos: BlockPos) -> bool { + let radius = self.rules.fire_spread_radius_around_player; + radius == -1 + || self.fire_players.iter().any(|p| { + let d = [ + p[0] - f64::from(pos.x), + p[1] - f64::from(pos.y), + p[2] - f64::from(pos.z), + ]; + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < f64::from(radius) * f64::from(radius) + }) + } + fn increased_fire_burnout(&self, pos: BlockPos) -> bool { + self.world + .chunks + .get(&(pos.x >> 4, pos.z >> 4)) + .is_some_and(|chunk| { + self.biomes + .get(chunk.biome(pos.x, pos.y, pos.z)) + .increased_fire_burnout + }) + } fn block_state(&self, pos: BlockPos) -> BlockStateId { self.world.block(pos.x, pos.y, pos.z) } @@ -759,6 +808,11 @@ impl LevelAccess for GameLevel<'_> { fn sky_darken(&self) -> i32 { self.darken } + fn sun_angle(&self, _pos: BlockPos) -> f32 { + self.environment + .sample(self.world.day_time, None, None) + .sun_angle + } fn is_raining(&self) -> bool { self.raining } @@ -827,8 +881,9 @@ impl LevelAccess for GameLevel<'_> { fn is_ticking_chunk(&self, chunk: horizon_core::position::ChunkPos) -> bool { self.tickets.block_ticking(chunk.x, chunk.z) && ticking_chunk(self.world, chunk.x, chunk.z) } - /// Entities that trigger pressure plates, tripwires and detector rails: - /// the players (the host and the LAN guests, mobs) and the minecarts. + /// Entities that trigger pressure plates, tripwires, detector rails and + /// wooden buttons: the players (the host and the LAN guests, mobs) and + /// the minecarts, and for `EntityQuery::Arrows` the arrows. fn count_entities(&self, area: Aabb, query: EntityQuery) -> usize { let hit = |(lo, hi): &([f64; 3], [f64; 3])| { area.intersects(Aabb::new( @@ -837,6 +892,7 @@ impl LevelAccess for GameLevel<'_> { )) }; let carts = || self.cart_boxes.iter().filter(|b| hit(b)).count(); + let arrows = || self.arrow_boxes.iter().filter(|b| hit(b)).count(); let players = || { self.player_box .iter() @@ -846,6 +902,7 @@ impl LevelAccess for GameLevel<'_> { }; match query { EntityQuery::Minecart => carts(), + EntityQuery::Arrows => arrows(), EntityQuery::Mobs => players(), EntityQuery::Everything => players() + carts(), } @@ -853,7 +910,16 @@ impl LevelAccess for GameLevel<'_> { fn emit(&mut self, event: LevelEvent) { match event { // Block.dropResources draws its loot and spots right away. - LevelEvent::DropResources { pos, state } => self.drop_resources_now(pos, state), + // `popResource` only drops server-side (`!level.isClientSide`): + // a client's moveBlocks (a piston's block event) skips it. + LevelEvent::DropResources { pos, state } if !self.client_side => { + self.drop_resources_now(pos, state) + } + LevelEvent::DropResources { .. } => {} + // `ClientLevel.playSeededSound` plays only when the excluded + // player is this client's own: the block's `playSound(null, ...)` + // (a piston's move, a note) reaches it from the server instead. + LevelEvent::Sound { .. } if self.client_side => {} // Block.popResource of the block's own item (tall flowers, flower beds). LevelEvent::BlockEvent { pos, @@ -1266,6 +1332,13 @@ struct Game<'a> { /// Controllers (Horizon's own). pad: pad::Pads, frames: u64, + /// `item_level` for one frame, game time, day time and dimension. + item_level_cache: std::cell::RefCell< + Option<( + (u64, i64, i64, &'static str), + horizon_assets::item_model::LevelState, + )>, + >, /// Frames drawn with the player in the world, for `--world-frames`. world_frames: u64, fps: f32, @@ -1429,6 +1502,8 @@ struct Game<'a> { map_frame_model: frames::FrameModel, glow_map_frame_model: frames::FrameModel, armor_stands: Vec, + /// `Cushion` decoration entities (game/cushions.rs). + cushions: Vec, /// Entities of types Horizon does not simulate. others: Vec, armor_stand_assets: armor_stands::ArmorStandAssets, @@ -1444,6 +1519,8 @@ struct Game<'a> { boat_models: boats::BoatModels, /// EndCrystalModel's baked layer. crystal_model: Option, + /// CushionModel's baked layer. + cushion_model: Option, carts: Vec, /// Songs playing from jukeboxes (tracked sound handles). jukebox_sounds: HashMap<(i32, i32, i32), u64>, @@ -1580,6 +1657,8 @@ struct Game<'a> { /// position) for the guests. lan_block_events: Vec<([i32; 3], i32, i32, u32)>, lan_entity_events: Vec<(i32, i8, [f64; 3])>, + /// Successful crits (attacking guest, hit entity), queued while the hub is taken. + lan_critical_hits: Vec<(Option, i32)>, /// Global level events for every guest (`ServerLevel.globalLevelEvent`). lan_global_events: Vec<(i32, [i32; 3], i32)>, /// `ContainerOpenersCounter`: how many players (the host and the guests) @@ -2159,8 +2238,9 @@ impl Game<'_> { ticks: 0, }); } - /// `Block.getDrops`: the block's loot table, drawing from its server random - /// sequence (`LootContext.Builder.create`), else the level random. + /// `Block.getDrops` for a block a player breaks: the block's loot table + /// with the player as `this`, drawing from its server random sequence + /// (`LootContext.Builder.create`), else the level random. fn block_loot_at( &mut self, pos: [i32; 3], @@ -2174,9 +2254,9 @@ impl Game<'_> { let random = self.rand_sequences.get(&sequence, self.level.seed); self.res .items() - .block_drops_with(registry, state, held, &dynamic, random) + .block_drops_by_player(registry, state, held, &dynamic, random) } - None => self.res.items().block_drops_with( + None => self.res.items().block_drops_by_player( registry, state, held, @@ -2220,11 +2300,13 @@ impl Game<'_> { // Player.drop: the drop statistics. self.stats.add("dropped", &thrown.item, thrown.count as i32); self.stats.custom("drop", 1); - self.throw_from_eyes(thrown, eye); + self.throw_from_eyes(thrown, eye, true); } /// `Player.createItemStackToDrop`: an item entity thrown from the eyes - /// along the view direction. - fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) { + /// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the + /// drop key) names the player as the thrower; `drop(stack, false)`, for + /// what no longer fits in the inventory, does not. + fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3], thrown_from_hand: bool) { let (yaw, pitch) = ( f64::from(self.yaw).to_radians(), f64::from(self.pitch).to_radians(), @@ -2238,7 +2320,7 @@ impl Game<'_> { yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength, ]; self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40); - if self.lan_acting.is_none() { + if thrown_from_hand && self.lan_acting.is_none() { let uuid = self.level.ensure_player_uuid(); self.dropped.last_mut().unwrap().thrower = Some(uuid); } @@ -2272,7 +2354,13 @@ impl Game<'_> { let speed = self.world.random.next_float() * 0.2 + 0.1; item.move_towards_closest_space(&self.world, self.res.facts(), speed); } - let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics); + let alive = item.tick( + &self.world, + self.res.facts(), + self.collision, + no_physics, + &self.res.motion().fluids, + ); cells.moved(i, item.previous, item.position); // ItemEntity.tick: merge every 2 ticks while moving between // blocks, every 40 otherwise, before the lifetime check. @@ -2439,6 +2527,18 @@ impl Game<'_> { ) }) .collect(); + let arrow_boxes = self + .creatures + .projectiles + .iter() + .filter(|p| p.arrow.is_some()) + .map(|p| p.position) + .chain(self.tridents.iter().map(|t| t.position)) + .map(|at| { + let b = horizon_client::arrows::arrow_bounds(at); + (b.min_position().components(), b.max_position().components()) + }) + .collect(); let guest_boxes = self.lan_block_trigger_boxes(); let dimension = match self.dim { Dim::Overworld => LevelDimension::Overworld, @@ -2446,6 +2546,19 @@ impl Game<'_> { Dim::End => LevelDimension::End, }; let rules = self.level_rules(); + let fire_players: Vec<[f64; 3]> = self + .player + .iter() + .filter(|_| !spectator) + .map(|p| [p.position.x, p.position.y, p.position.z]) + .chain(self.lan.iter().flat_map(|h| { + h.hub + .guests + .iter() + .filter(|g| h.hub.here(g) && !g.spectator()) + .map(|g| g.position) + })) + .collect(); let blockers = Blockers { mobs: &self.mobs, armor_stands: &self.armor_stands, @@ -2456,6 +2569,7 @@ impl Game<'_> { }; let mut level = GameLevel { cart_boxes, + arrow_boxes, blockers, world: &mut self.world, facts: self.res.facts(), @@ -2463,6 +2577,11 @@ impl Game<'_> { events: &mut self.level_events, dimension, darken, + environment: match dimension { + LevelDimension::Overworld => self.res.environment(), + LevelDimension::Nether => self.res.nether_environment(), + LevelDimension::End => self.res.end_environment(), + }, raining: rain > 0.2, thundering: thunder > 0.9, player_box, @@ -2476,12 +2595,15 @@ impl Game<'_> { loot: horizon_client::structure_blocks::loot_data(&self.options.resources), registry: self.pack.assets.registry, rules, + difficulty: i32::from(self.ui.difficulty), + fire_players, rand_sequences: &mut self.rand_sequences, pending: &mut self.pending, spill: &mut self.spill, tickets: self.tickets.levels(), random_tick_sections: &mut self.random_tick_sections, lid_openers: &self.lid_openers, + client_side: self.remote.is_some(), }; let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks); self.flush_spill(); @@ -2543,14 +2665,15 @@ impl Game<'_> { (p.position.z.floor() as i32) >> 4, )); } - let sources = (self.options.dedicated.is_some() || !players.is_empty()).then(|| { - horizon_client::ticket_levels::Sources { - players, - forced: self.streaming.forced.iter().copied().collect(), - view_distance: self.settings.render_distance.clamp(2, 32), - simulation_distance: self.settings.simulation_distance.max(2), - } - }); + let sources = + (!self.options.execution_role.is_client() || !players.is_empty()).then(|| { + horizon_client::ticket_levels::Sources { + players, + forced: self.streaming.forced.iter().copied().collect(), + view_distance: self.settings.render_distance.clamp(2, 32), + simulation_distance: self.settings.simulation_distance.max(2), + } + }); self.tickets.update(sources); } /// `ServerLevel.startTickingChunk`: a loaded chunk's saved ticks are @@ -2585,8 +2708,9 @@ impl Game<'_> { .copied() .filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)) .collect(); - // Entity.checkInsideBlocks for the plates, tripwires and detector rails - // under the players (the host and the LAN guests) and the minecarts. + // Entity.checkInsideBlocks for the plates, tripwires, detector rails and + // buttons under the players (the host and the LAN guests), the minecarts + // and the arrows. let mut inside = Vec::new(); let guests = self.lan_block_trigger_boxes(); let boxes = self @@ -2618,6 +2742,24 @@ impl Game<'_> { } } } + // AbstractArrow.applyEffectsFromBlocks: the blocks every arrow passed + // through this tick (a wooden button is pressed by one inside it), and + // the ones a thrown trident is in. + for p in self + .creatures + .projectiles + .iter() + .filter(|p| p.arrow.is_some()) + { + inside.extend(horizon_client::arrows::blocks_inside( + p.previous, p.position, + )); + } + for t in &self.tridents { + inside.extend(horizon_client::arrows::blocks_inside( + t.position, t.position, + )); + } // BigDripleafBlock.entityInside also asks whether the player stands on // the ground and how high its feet are: the leaves its box's feet // layer is inside (the host and the LAN guests). @@ -2779,6 +2921,9 @@ impl Game<'_> { pos, data, } => self.consume_teleport_particles([pos.x, pos.y, pos.z], data), + LevelEvent::LevelEvent { id: 2003, pos, .. } => { + self.eye_break_particles([pos.x, pos.y, pos.z]); + } LevelEvent::LevelEvent { id: 2013, pos, @@ -2809,6 +2954,9 @@ impl Game<'_> { 1501 => Some(("minecraft:block.lava.extinguish", 0.5)), 1009 => Some(("minecraft:block.fire.extinguish", 0.5)), 1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)), + // LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break). + 1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)), + 1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)), 1030 => Some(("minecraft:block.anvil.use", 1.0)), 1029 => Some(("minecraft:block.anvil.destroy", 1.0)), 1031 => Some(("minecraft:block.anvil.land", 0.3)), @@ -2828,9 +2976,14 @@ impl Game<'_> { 1029 | 1030 | 1042 | 1043 | 1044 => { self.world.random.next_float() * 0.1 + 0.9 } + 1019 | 1021 => { + (self.world.random.next_float() - self.world.random.next_float()) + * 0.2 + + 1.0 + } _ => 1.0, }; - if matches!(id, 1029 | 1030) { + if matches!(id, 1019 | 1021 | 1029 | 1030) { // Clients play the anvil sound from its level event; // capturing it also sends a duplicate sound packet. self.audio.play_unshared(name, c, volume, pitch); @@ -2960,6 +3113,11 @@ impl Game<'_> { kind: "hatch_sniffer", .. } => self.hatch_sniffer([pos.x, pos.y, pos.z]), + LevelEvent::BlockEvent { + pos, + state, + kind: "hatch_turtles", + } => self.hatch_turtles([pos.x, pos.y, pos.z], state), LevelEvent::BlockEvent { pos, kind: "portal_spawn", @@ -2997,6 +3155,10 @@ impl Game<'_> { .name .clone(); let key = (pos[0], pos[1], pos[2]); + if self.chest_menu_blocked(key, &name) { + // The use still succeeds, but no menu, stat or piglin anger. + return; + } if &*name == "minecraft:lectern" { self.open_lectern(pos); return; @@ -3327,6 +3489,13 @@ impl Game<'_> { self.with_player_actor(|game| game.break_block_as_player(pos)) } fn break_block_as_player(&mut self, pos: [i32; 3]) -> Result<()> { + if !self + .res + .items() + .can_destroy_block(self.held(), self.creative) + { + return Ok(()); + } self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken); // ServerPlayerGameMode.destroyBlock. let state = self.world.block(pos[0], pos[1], pos[2]); @@ -3473,6 +3642,7 @@ impl Game<'_> { fn interact_entity(&mut self) -> Result { Ok(self.interact_frame() || self.interact_armor_stand() + || self.interact_cushion() || self.interact_boat() || self.interact_cart() || self.interact_mob()? @@ -3534,6 +3704,10 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.use_campfire_douse(hit.pos, held.as_ref())? { + self.swing_arm(); + return Ok(true); + } match self.use_respawn_anchor(hit.pos, held.as_ref())? { respawn_anchor::AnchorUse::Charged => { self.swing_arm(); @@ -3659,6 +3833,7 @@ impl Game<'_> { || self.place_painting(hit, &stack) || self.place_item_frame(hit, &stack) || self.place_armor_stand(hit, &stack) + || self.place_cushion(hit, &stack) || self.place_end_crystal(hit, &stack)) { return Ok(true); @@ -3950,7 +4125,12 @@ impl Game<'_> { return Ok(()); }; // GameType.isBlockPlacingRestricted: adventure and spectator. - if !self.may_build() { + if !self.may_build() + || !self + .res + .items() + .can_destroy_block(self.held(), self.creative) + { self.mining = None; return Ok(()); } @@ -4199,6 +4379,9 @@ impl Game<'_> { self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags)); } fn tick(&mut self) -> Result<()> { + if self.agent.hold_server { + return Ok(()); + } self.tick_debug_keys()?; self.tick_debug_charts(); if self.remote.is_some() { @@ -4221,8 +4404,9 @@ impl Game<'_> { if running { self.world.game_time += 1; if self.ui.rules.bool("advance_weather") { - self.world.weather.tick(&mut self.world.random); + self.world.weather.tick_timers(&mut self.world.random); } + self.world.weather.tick_levels(); self.tick_weather_effects(); } if self.ticks.is_multiple_of(6000) { @@ -4321,12 +4505,6 @@ impl Game<'_> { let events = std::mem::take(&mut player.events); let spin_hit = player.spin_hit.take(); let glider_wear = std::mem::take(&mut player.glider_wear); - if glider_wear > 0 - && !self.creative - && let Some(elytra) = self.armor[1].as_mut() - { - elytra.damage += glider_wear; - } let feet = [player.position.x, player.position.y, player.position.z]; let player_velocity = [player.velocity.x, player.velocity.y, player.velocity.z]; self.previous_eye = self.eye; @@ -4337,9 +4515,20 @@ impl Game<'_> { }; self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5; let crouching = player.pose == horizon_client::pose::Pose::Crouching; + // `ItemStack.hurtAndBreak` for the glide: Unbreaking can save the wear. + if glider_wear > 0 + && !self.creative + && let Some(stack) = self.armor[1].clone() + { + let loss = self.durability_loss(&stack, glider_wear); + if let Some(elytra) = self.armor[1].as_mut() { + elytra.damage += loss; + } + } self.tick_avatar(before, riding); self.end_spin_attack(spin_hit); self.record_combat(&events); + self.wear_armor(&events); for event in events { match event { MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet), @@ -4512,7 +4701,7 @@ impl Game<'_> { && let Some(stack) = self.carried.take() && let Some(eye) = self.eye_position() { - self.throw_from_eyes(stack, eye); + self.throw_from_eyes(stack, eye, false); } } self.tick_world(running) @@ -4541,6 +4730,7 @@ impl Game<'_> { horizon_client::spans::time("tick_paintings", || self.tick_paintings()); horizon_client::spans::time("tick_item_frames", || self.tick_item_frames()); horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands()); + horizon_client::spans::time("tick_cushions", || self.tick_cushions()); horizon_client::spans::time("tick_boats", || self.tick_boats()); horizon_client::spans::time("tick_carts", || self.tick_carts()); horizon_client::spans::time("tick_thunder", || self.tick_thunder()); @@ -4749,7 +4939,10 @@ impl Game<'_> { if capture && !self.focused { return; } - if !capture { + if capture { + // The inventory's screens are behind us: no drag survives them. + self.reset_inventory_clicks(); + } else { self.stop_mining(); } // The real pointer takes over from an agent's. @@ -4828,7 +5021,7 @@ impl Game<'_> { self.capture(true); } } - fn draw_item(&mut self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) { + fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) { // `GuiGraphics.renderItem`: the resolved item model (tints, foil and // special renderers included), then the decorations. self.draw_item_model(batch, stack, x, y); @@ -4918,7 +5111,7 @@ impl Game<'_> { /// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`) /// and damage bar. fn draw_item_decorations( - &mut self, + &self, batch: &mut HudBatch, stack: &ItemStack, x: f32, @@ -5045,12 +5238,12 @@ impl Game<'_> { for d in &draws { if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d { let stack = if slot == 40 { - self.offhand.clone() + self.offhand.as_ref() } else { - self.inventory[slot as usize].clone() + self.inventory[slot as usize].as_ref() }; if let Some(stack) = stack { - self.draw_item(&mut batch, &stack, x as f32, y as f32); + self.draw_item(&mut batch, stack, x as f32, y as f32); } } } @@ -5275,7 +5468,9 @@ impl Game<'_> { | BlockEntity::Vault(_) | BlockEntity::Beehive(_) | BlockEntity::CommandBlock(_) - | BlockEntity::Skull(_), + | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector + | BlockEntity::Campfire(_), ) => {} None => { v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i)); @@ -5545,7 +5740,9 @@ impl Game<'_> { | BlockEntity::Vault(_) | BlockEntity::Beehive(_) | BlockEntity::CommandBlock(_) - | BlockEntity::Skull(_), + | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector + | BlockEntity::Campfire(_), ) => {} None if self.mount_active() => self.draw_mount_background(batch, ox, oy), None => { @@ -5588,7 +5785,18 @@ impl Game<'_> { self.draw_inventory_effects(batch, (ox, oy), 176); } } + /// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it + /// belongs to on the screen; Horizon keeps them on the game, so they are + /// dropped when a screen closes. A press whose release never reaches the + /// screen (the pointer left the window, or the screen closed first) would + /// otherwise leave every later press on every later screen swallowed. + pub(super) fn reset_inventory_clicks(&mut self) { + self.inv_clicks = Default::default(); + self.inv_drag = None; + self.last_inv_click = None; + } pub(super) fn close_container(&mut self) { + self.reset_inventory_clicks(); self.close_station(); self.mount_closed(); self.recipe_ui.reset_screen(); @@ -5601,12 +5809,15 @@ impl Game<'_> { if let Some(rest) = self.give(stack) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40); + // AbstractContainerMenu.dropOrPlaceInInventory: `drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } self.craft_size = 2; self.craft_table = None; if let Some(key) = self.container.take() { + // A cart's or chest boat's items go back to it. + self.close_cart_container(key); self.container_opener_event(key, false); self.post_block_game_event( "minecraft:container_close", @@ -5646,7 +5857,6 @@ impl Game<'_> { self.world.modified.insert((key.0 >> 4, key.2 >> 4)); } self.close_ender_chest(); - self.close_cart_container(); } /// `quickMoveStack` of the open plain menu for a shift-click. fn inventory_quick_move(&mut self, slot: usize) -> bool { @@ -5683,8 +5893,9 @@ impl Game<'_> { if self.creative_screen.tab().search { // CreativeModeInventoryScreen.slotClicked: moveCursorToEnd, then // setHighlightPos(0), so typing replaces the whole query. - let search = &self.creative_screen.search; - self.text_selection.select(("creative.search", 0), search); + let search = &mut self.creative_screen.search; + search.move_cursor_to_end(false); + search.highlight = 0; } let items = self.creative_screen.items(&self.pack.names); let first = self.creative_screen.first_row(&self.pack.names); @@ -6326,6 +6537,13 @@ impl Game<'_> { false, ); if outside { + // AbstractRecipeBookScreen.mouseClicked: the book takes the click + // before the outside test, and its tabs sit left of the strip + // hasClickedOutside counts as the book. + if self.recipe_book_click(right) { + self.inv_clicks.skip_release(); + return; + } // slotId -999: an empty hand throws nothing; a carried stack // starts a drag whose release drops it. match input { @@ -6891,6 +7109,9 @@ impl Game<'_> { self.prepare_entity_culling(camera); self.prepare_trims(); self.mark_entity(out.len(), "minecraft:entity_flame"); + // One scan for both passes that draw them (block entities below and the + // overlay near the end of this function). + let cracks = self.cracks(camera); let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront { self.yaw + 180.0 } else { @@ -6987,6 +7208,9 @@ impl Game<'_> { // `VaultRenderer`. self.mark_entity(out.len(), ""); self.vault_geometry(camera, alpha, &mut out); + // `CampfireRenderer`. + self.mark_entity(out.len(), ""); + self.campfire_geometry(camera, &mut out); self.mark_entity(out.len(), "minecraft:ominous_item_spawner"); self.ominous_item_spawner_geometry(camera, alpha, &mut out); // Mobs and their projectiles (a span each). @@ -7116,7 +7340,7 @@ impl Game<'_> { .render(&scene, visible, &mut out); } self.mark_stage(out.len(), "DESTROY"); - for (pos, stage) in self.cracks(camera) { + for &(pos, stage) in &cracks { let state = self.world.block(pos[0], pos[1], pos[2]); self.res .block_entity_render() @@ -7229,6 +7453,7 @@ impl Game<'_> { self.frame_geometry(camera, &mut out); self.mark_entity(out.len(), "minecraft:armor_stand"); self.armor_stand_geometry(camera, alpha, &mut out); + self.cushion_geometry(camera, &mut out); self.mark_entity(out.len(), "minecraft:oak_boat"); self.boat_geometry(camera, alpha, &mut out); self.mark_entity(out.len(), "minecraft:minecart"); @@ -7259,7 +7484,7 @@ impl Game<'_> { ); // Breaking progress overlays (destroy_stage_0..9): ours and the other players'. self.mark_stage(out.len(), "DESTROY"); - for (pos, stage) in self.cracks(camera) { + for &(pos, stage) in &cracks { let o = (glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])) - camera) .as_vec3(); @@ -7307,6 +7532,20 @@ impl Game<'_> { /// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the /// furthest progress on each block). fn cracks(&self, camera: glam::DVec3) -> Vec<([i32; 3], [f32; 2])> { + // `destroy_stage_0` .. `destroy_stage_9`: the sprite name by stage, so the + // lookup does not format a string per crack. + const DESTROY_STAGES: [&str; 10] = [ + "block/destroy_stage_0", + "block/destroy_stage_1", + "block/destroy_stage_2", + "block/destroy_stage_3", + "block/destroy_stage_4", + "block/destroy_stage_5", + "block/destroy_stage_6", + "block/destroy_stage_7", + "block/destroy_stage_8", + "block/destroy_stage_9", + ]; let mut stages: Vec<([i32; 3], u32)> = Vec::new(); let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) { Some(c) => c.1 = c.1.max(stage), @@ -7332,11 +7571,8 @@ impl Game<'_> { d.length_squared() <= 1024.0 }) .filter_map(|(pos, stage)| { - let r = self - .gui - .atlas - .sprites - .get(&format!("block/destroy_stage_{stage}"))?; + let name = DESTROY_STAGES.get(stage as usize)?; + let r = self.gui.atlas.sprites.get(*name)?; Some((pos, [r.u0, r.v0])) }) .collect() @@ -7461,6 +7697,69 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0); } + /// The damage and knockback part of `Player.doSweepAttack` after a sweep + /// hit on the mob at `target`: every other living mob in its box grown by + /// (1, 0.25, 1) and closer than 3 blocks (`distanceToSqr < 9`, in creative + /// too), but the player's allies + /// (`isAlliedTo`), takes `1 + sweeping_damage_ratio * baseDamage` with the + /// weapon's enchantments, scaled by the attack strength, and is pushed 0.4 + /// along the player's yaw. + pub(super) fn sweep_damage(&mut self, target: usize, scale: f32) { + let Some(p) = self.player.as_ref() else { + return; + }; + let from = [p.position.x, p.position.y, p.position.z]; + // `baseDamage` of `Player.attack` before a critical hit (a sweep has + // none) and the enchantments: the attack damage attribute with the + // strength and weakness effects, scaled by the attack strength. + let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage); + let base_damage = (hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8); + // The enchantment's attribute modifier on the attribute's 0 to 1 range. + let ratio = self + .res + .stations() + .enchantments + .attribute_add( + self.inventory[self.selected].as_ref(), + "minecraft:sweeping_damage_ratio", + ) + .clamp(0.0, 1.0) as f32; + let sweep_damage = 1.0 + ratio * base_damage; + let hit_box = self.mobs[target] + .bounds() + .inflate(horizon_core::geometry::Vec3::new(1.0, 0.25, 1.0)); + let (uuid, allies) = ( + self.level.player_uuid.or(Some([0, 0, 0, 1])), + self.player_allies(), + ); + let nearby: Vec = self + .mobs + .iter() + .enumerate() + .filter(|(i, m)| { + *i != target + && m.alive() + && !m.removed + && !allies.contains(&m.id) + && !mace::ally_of_player(m, uuid) + && m.bounds().intersects(hit_box) + && m.position.distance_squared(p.position) < 9.0 + }) + .map(|(i, _)| i) + .collect(); + let (xd, zd) = self.extra_knockback_direction(); + for i in nearby { + let kind = self.mobs[i].kind.id.clone(); + let kind = format!("minecraft:{}", kind.trim_start_matches("minecraft:")); + let damage = (sweep_damage + self.enchant_damage_bonus(&kind)) * scale; + let source = self.attack_source(from); + if self.hurt_by_player(i, damage, source) { + self.mobs[i].knockback(0.4, xd, zd); + self.last_hurt_by_player + .insert(self.mobs[i].id, self.lan_acting); + } + } + } /// `Player.attack`'s sounds when a vehicle takes the hit (it always does, /// and can't be hit critically): knockback when sprinting at full /// strength, then strong or weak. Before `swing_arm` resets the charge. @@ -7514,6 +7813,7 @@ impl Game<'_> { .map_or(0.0, |i| i.attack_damage); if self.attack_frame() || self.attack_armor_stand() + || self.attack_cushion() || self.attack_boat(hand_damage) || self.attack_cart(hand_damage) || self.attack_dragon() @@ -7601,10 +7901,9 @@ impl Game<'_> { self.stats .custom("damage_dealt", (actual * 10.0).round() as i32); if crit { - self.particles.tracking_emitter( - horizon_client::particles::ParticleOptions::simple( - horizon_client::particles::ParticleType::Crit, - ), + self.critical_hit( + self.lan_acting, + self.mobs[index].id as i32, feet, width, height, @@ -7634,6 +7933,7 @@ impl Game<'_> { // Player.playServerSideSound: at the player. if let Some(at) = sweep { self.sweep_attack(at, from); + self.sweep_damage(index, scale); } else { self.audio.play(sound, from, 1.0, 1.0); } @@ -7644,6 +7944,9 @@ impl Game<'_> { if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event { self.audio .play(&format!("minecraft:{s}"), Some(at), volume, pitch); + } else { + // Left for the end of the tick. + self.mobs[index].outbox.push(event); } } self.weapon_hit(index); @@ -7653,15 +7956,18 @@ impl Game<'_> { } } /// ServerExplosion: rays from a 16^3 grid lose (resistance + 0.3) * 0.3 per step. - /// A mob explosion (`Level.ExplosionInteraction.MOB`: drop decay with the - /// default mobGriefing rule). + /// A mob explosion (`ServerLevel.explode` with `ExplosionInteraction.MOB`: + /// blocks are kept without mobGriefing, else the mob_explosion_drop_decay + /// rule picks decay). fn explode(&mut self, center: [f64; 3], radius: f32) -> Result<()> { - self.server_explode( - center, - radius, - explosion::BlockInteraction::DestroyWithDecay, - false, - ) + let interaction = if !self.ui.rules.bool("mob_griefing") { + explosion::BlockInteraction::Keep + } else if self.ui.rules.bool("mob_explosion_drop_decay") { + explosion::BlockInteraction::DestroyWithDecay + } else { + explosion::BlockInteraction::Destroy + }; + self.server_explode(center, radius, interaction, false) } /// Copies of a save batch carrying its live block entities and save data. fn with_entities<'a>( @@ -7986,6 +8292,7 @@ impl Game<'_> { /// (scaled coordinates, or the End portal's fixed destination). pub(super) fn switch_level(&mut self, target: Dim, from: Vec3) -> Result { // Save the departing dimension's changed chunks. + self.save.save_forced_chunks(&self.streaming.forced)?; self.streaming.save(&mut self.world, true); let keys: Vec<_> = self.world.modified.drain().collect(); let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k))); @@ -8259,6 +8566,7 @@ impl Game<'_> { self.boats.clear(); self.carts.clear(); self.armor_stands.clear(); + self.cushions.clear(); self.item_frames.clear(); self.paintings.clear(); self.others.clear(); @@ -8313,15 +8621,54 @@ impl Game<'_> { } self.apply_enchantment_attributes(); } - /// Inventory.hurtArmor: each piece loses max(1, damage / 4) durability. - fn hurt_armor(&mut self, damage: f32) { + /// The armor hits among a tick's motion events (`Player.hurtArmor` from + /// `getDamageAfterArmorAbsorb`), whatever the damage: a mob, an explosion, + /// a cactus, lava, a campfire. + fn wear_armor(&mut self, events: &[MotionEvent]) { + for event in events { + if let MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { + source, + amount, + }) = event + { + self.hurt_armor(*source, *amount); + } + } + } + /// `Player.hurtArmor` -> `LivingEntity.doHurtEquipment`: each worn piece that + /// wears when its wearer is hurt loses max(1, damage / 4) durability, except + /// to a fire source when the piece resists fire (`ItemStack.canBeHurtBy`). + fn hurt_armor(&mut self, source: horizon_world::damage_source::DamageTypeId, damage: f32) { + if damage <= 0.0 { + return; + } let amount = ((damage / 4.0) as u32).max(1); + let name = self + .motion + .health + .damage + .definition(source) + .map(|d| d.name.clone()) + .unwrap_or_default(); + let fire = self + .motion + .health + .damage + .in_tag(source, "minecraft:is_fire"); let mut broke = false; let losses: Vec = (0..4) - .map(|i| { - self.armor[i] - .clone() - .map_or(amount, |s| self.durability_loss(&s, amount)) + .map(|i| match self.armor[i].clone() { + Some(s) + if self + .res + .items() + .get(&s.item) + .is_some_and(|info| info.equip_damage_on_hurt) + && !(fire && self.res.items().resists_fire(&s, &name)) => + { + self.durability_loss(&s, amount) + } + _ => 0, }) .collect(); for (slot, amount) in self.armor.iter_mut().zip(losses) { @@ -8418,7 +8765,8 @@ impl Game<'_> { } fn drop_stack(&mut self, stack: ItemStack) { if let Some(eye) = self.0.eye_position() { - self.0.spawn_item(stack, eye, [0.0, 0.1, 0.0], 40); + // ItemUtils.createFilledResult: `player.drop(stack, false)`. + self.0.throw_from_eyes(stack, eye, false); } } } @@ -8656,6 +9004,24 @@ impl Game<'_> { } } } + /// `ItemStack.hurtWithoutBreaking`: Unbreaking applies, but the stack stops at + /// one point of durability left instead of breaking. + fn hurt_held_without_breaking(&mut self, amount: u32) { + if self.creative { + return; + } + let Some(held) = self.held().cloned() else { + return; + }; + let amount = self.durability_loss(&held, amount); + let max = self.res.items().get(&held.item).map_or(0, |i| i.max_damage); + if amount == 0 || max == 0 { + return; + } + if let Some(stack) = self.held_mut().as_mut() { + stack.damage = (stack.damage + amount).min(max - 1); + } + } /// Block tool use: distinguish success, failure and fall-through for hand feedback. fn use_tool( &mut self, @@ -8677,6 +9043,13 @@ impl Game<'_> { { return Ok(UseResult::Success { swing: true }); } + if allow_block_actions + && let Some(hit) = self.pick() + && let Some(placed) = self.use_on_campfire(hit.pos)? + { + // SUCCESS_SERVER swings; a full campfire's CONSUME does not. + return Ok(UseResult::Success { swing: placed }); + } // BucketItem has no useOn: a guest's UseItemOn leaves it to UseItem. let bucket = horizon_client::items::bucket_contents(&stack.item); if matches!(self.guest_use, Some(GuestUse::OnBlock(_))) @@ -8736,7 +9109,8 @@ impl Game<'_> { } else if let Some(rest) = self.give(filled) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40); + // ItemUtils.createFilledResult: `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } return Ok(UseResult::Success { swing: true }); @@ -8886,8 +9260,29 @@ impl Game<'_> { while value > 0 { let v = ExperienceOrb::split(value); value -= v; - let _merge_id = self.world.random.next_int_bound(40); - self.add_orb(at, v); + let merge_id = self.world.random.next_int_bound(40).unwrap_or(0); + self.award_orb(at, v, merge_id); + } + } + /// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a + /// 1 block box of `at`, whose id is a multiple of 40 from `merge_id`, takes + /// the new one as one more in its count (and restarts its age); otherwise + /// a fresh orb spawns. + fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) { + let centre = Vec3::new(at[0], at[1], at[2]); + let half = Vec3::new(0.5, 0.5, 0.5); + let area = Aabb::new(centre.subtract(half), centre.add_vector(half)); + // A server's orbs are its own; a LAN or server guest only shows them. + let target = self.remote.is_none().then(|| { + self.orbs + .iter_mut() + .find(|o| o.can_merge(merge_id as u32, value) && o.bounds().intersects(area)) + }); + if let Some(Some(orb)) = target { + orb.count += 1; + orb.age = 0; + } else { + self.add_orb(at, value); } } /// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from @@ -8909,6 +9304,8 @@ impl Game<'_> { value, age: 0, on_ground: false, + count: 1, + ticks: 0, }); } fn can_place_used_fire(&mut self, target: [i32; 3]) -> bool { @@ -8985,18 +9382,31 @@ impl Game<'_> { }), ); } + // Player.aiStep: the pickup area is the box inflated by (1, 0.5, 1). let bounds = player.map(|p| { let w = f64::from(p.dimensions.width) / 2.0; ( - [p.position.x - w, p.position.y, p.position.z - w], [ - p.position.x + w, - p.position.y + f64::from(p.dimensions.height), - p.position.z + w, + p.position.x - w - 1.0, + p.position.y - 0.5, + p.position.z - w - 1.0, + ], + [ + p.position.x + w + 1.0, + p.position.y + f64::from(p.dimensions.height) + 0.5, + p.position.z + w + 1.0, ], ) }); let mut orbs = std::mem::take(&mut self.orbs); + // An orb has no random of its own here: lava draws from one made on + // first use, apart from the level stream. + let mut lava_random = None; + let mut next_float = || { + lava_random + .get_or_insert_with(dispenser::entity_random) + .next_float() + }; orbs.retain_mut(|o| { let distance = |feet: &[f64; 3]| -> f64 { (feet[0] - o.position.x).powi(2) @@ -9008,12 +9418,22 @@ impl Game<'_> { .filter(|(feet, _)| distance(feet) < 64.0) .min_by(|a, b| distance(&a.0).total_cmp(&distance(&b.0))) .map(|(_, target)| *target); - o.tick(&self.world, self.res.facts(), self.collision, target) + o.tick( + &self.world, + self.res.facts(), + self.collision, + target, + &mut next_float, + ) }); + // ExperienceOrb.scanForMerges runs on the server only. + if self.remote.is_none() { + horizon_client::entities::merge_orbs(&mut orbs); + } let mut gained = 0; let mut taken = Vec::new(); if let Some((min, max)) = bounds { - orbs.retain(|o| { + orbs.retain_mut(|o| { let touching = o.position.x + 0.25 > min[0] && o.position.x - 0.25 < max[0] && o.position.y + 0.5 > min[1] @@ -9021,11 +9441,13 @@ impl Game<'_> { && o.position.z + 0.25 > min[2] && o.position.z - 0.25 < max[2]; if touching && self.xp_delay == 0 { - // ExperienceOrb.playerTouch: one orb every 2 ticks. + // ExperienceOrb.playerTouch: one orb every 2 ticks; a + // merged orb gives its value once per count. self.xp_delay = 2; gained += o.value; taken.push(o.clone()); - return false; + o.count -= 1; + return o.count > 0; } true }); @@ -9101,7 +9523,10 @@ impl Game<'_> { }; // BowItem.shootProjectile: shootFromRotation(power * 3, inaccuracy 1). let p = Vec3::new(eye[0], eye[1] - 0.1, eye[2]); - let (power_bonus, infinity) = self.bow_enchantments(); + let ammo = arrow_slot + .and_then(|i| self.stack_in(i).cloned()) + .unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1)); + let (power_bonus, infinity) = self.bow_enchantments(&ammo.item); let kind = horizon_client::projectiles::ProjectileKind::Arrow { base_damage: 2.0 + power_bonus, critical: power >= 1.0, @@ -9115,9 +9540,6 @@ impl Game<'_> { // ProjectileWeaponItem.useAmmo: creative and Infinity draw without // using the ammo, so the arrow is CREATIVE_ONLY; else setOwner(player) // lets it be picked up. - let ammo = arrow_slot - .and_then(|i| self.stack_in(i).cloned()) - .unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1)); if let Some(a) = arrow.arrow.as_mut() { a.set_item(&horizon_client::arrows::drawn_ammo( &ammo, @@ -9575,8 +9997,9 @@ impl Game<'_> { rain.extend(snow_vertices); rain } - /// Player.startSleepInBed: night or thunder, no monsters within 8 blocks; - /// the bed becomes the respawn point either way. + /// ServerPlayer.startSleepInBed: in reach, not obstructed, night or thunder, + /// and (outside creative) no monsters within 8 blocks; the bed becomes the + /// respawn point either way. fn try_sleep(&mut self, pos: [i32; 3]) { let Some(p) = self.player.as_ref() else { return; @@ -9591,22 +10014,40 @@ impl Game<'_> { .state(state) .map(|s| s.properties.clone()) .unwrap_or_default(); - let head = if props.get("part").map(String::as_str) == Some("foot") { - let d = facing_offset(props.get("facing").map_or("north", String::as_str)); - [pos[0] + d[0], pos[1], pos[2] + d[2]] + // The head and the foot: the block faces from the foot to the head. + let d = facing_offset(props.get("facing").map_or("north", String::as_str)); + let (head, foot) = if props.get("part").map(String::as_str) == Some("foot") { + ([pos[0] + d[0], pos[1], pos[2] + d[2]], pos) } else { - pos + (pos, [pos[0] - d[0], pos[1], pos[2] - d[2]]) }; - let dist = ((p.position.x - f64::from(head[0]) - 0.5).powi(2) - + (p.position.z - f64::from(head[2]) - 0.5).powi(2)) - .sqrt(); - if dist > 3.0 { + // ServerPlayer.bedInRange / isReachableBedBlock: within 3 blocks + // sideways and 2 vertically of the head or the foot. + let reachable = |bed: [i32; 3]| { + (p.position.x - f64::from(bed[0]) - 0.5).abs() <= 3.0 + && (p.position.y - f64::from(bed[1])).abs() <= 2.0 + && (p.position.z - f64::from(bed[2]) - 0.5).abs() <= 3.0 + }; + if !reachable(head) && !reachable(foot) { self.message = Some(( "You may not rest now; the bed is too far away".into(), Instant::now(), )); return; } + // ServerPlayer.bedBlocked: Player.freeAt (not suffocating) for the + // blocks above the head and the foot, as `in_wall` reads suffocating. + let blocked = [head, foot].iter().any(|bed| { + let f = self + .res + .facts() + .get(self.world.block(bed[0], bed[1] + 1, bed[2])); + f.full_collision && f.solid_render + }); + if blocked { + self.message = Some(("This bed is obstructed".into(), Instant::now())); + return; + } // In reach (bedInRange), day or night: setRespawnPosition( // RespawnConfig(the head, the player's yaw), not forced). It replaces // an anchor or a spawnpoint; respawning checks the bed is still there @@ -9636,13 +10077,15 @@ impl Game<'_> { )); return; } - let monsters = self.mobs.iter().any(|m| { - m.hostile() - && m.alive() - && (m.position.x - p.position.x).abs() < 8.0 - && (m.position.y - p.position.y).abs() < 5.0 - && (m.position.z - p.position.z).abs() < 8.0 - }); + // Creative players skip the monster check (`!this.isCreative()`). + let monsters = !self.creative + && self.mobs.iter().any(|m| { + m.hostile() + && m.alive() + && (m.position.x - p.position.x).abs() < 8.0 + && (m.position.y - p.position.y).abs() < 5.0 + && (m.position.z - p.position.z).abs() < 8.0 + }); if monsters { self.message = Some(( "You may not rest now; there are monsters nearby".into(), @@ -9692,14 +10135,20 @@ impl Game<'_> { } } // ServerLevel.tick: once enough of the players have slept 100 ticks, - // skip to morning, clear the weather and wake everyone. + // skip to morning if `advance_time` is on, wake everyone, and + // resetWeatherCycle if `advance_weather` is on and it is raining. if self.enough_slept() { - let t = self.world.day_time; - self.world.day_time = t + 24000 - t.rem_euclid(24000); - self.world.weather.raining = false; - self.world.weather.thundering = false; - self.world.weather.rain_time = 0; - self.world.weather.thunder_time = 0; + if self.ui.rules.bool("advance_time") { + let t = self.world.day_time; + self.world.day_time = t + 24000 - t.rem_euclid(24000); + } + // Level.isRaining: the rain level is above 0.2. + if self.ui.rules.bool("advance_weather") && self.world.weather.levels(1.0).0 > 0.2 { + self.world.weather.raining = false; + self.world.weather.thundering = false; + self.world.weather.rain_time = 0; + self.world.weather.thunder_time = 0; + } if self.sleeping.is_some() { self.wake_up(); } @@ -9727,8 +10176,9 @@ impl Game<'_> { fn draw_creative(&mut self, batch: &mut HudBatch) { let (ox, oy) = Self::creative_origin(batch); let selected = self.creative_screen.selected; - let tabs = self.creative_screen.tabs.clone(); - let tab = self.creative_screen.tab().clone(); + // Only what is drawn of the tabs: their stacks stay where they are. + let tabs = self.creative_screen.tab_headers(); + let tab = tabs[selected.min(tabs.len() - 1)].clone(); let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| { format!( "gui/sprites/container/creative_inventory/tab_{}_{}_{}", @@ -9794,20 +10244,16 @@ impl Game<'_> { ); } if tab.search { - // The search box: an unbordered EditBox of 80 at (82, 6), white. - let search = &self.creative_screen.search; - horizon_client::ui::append_field( - batch, - atlas, - search, - (ox + 82.0, oy + 6.0, 80.0), - (u32::MAX, true), - clipboard::CREATIVE_SEARCH_CHARS, - self.text_selection - .covers(clipboard::CREATIVE_SEARCH, search), - true, - self.creative_screen.search_focused.elapsed().as_millis(), - ); + // CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6). + let search = &mut self.creative_screen.search; + search.x = ox + 82.0; + search.y = oy + 6.0; + search.width = 80.0; + search.bordered = false; + search.focused = true; + search.text_color = u32::MAX; + search.max_length = clipboard::CREATIVE_SEARCH_CHARS; + search.draw(batch, atlas, None); } let mut hovered: Option = None; if tab.inventory { @@ -9915,8 +10361,7 @@ impl Game<'_> { } else if self.carried.is_none() && let Some(item) = hovered { - let lines = self.stack_tooltip(&item); - self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); + self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1); } self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32); } @@ -9969,6 +10414,9 @@ impl Game<'_> { } return; } + if !right && self.creative_search_click(ox, oy) { + return; + } // Holding the scrollbar scrolls as the mouse moves (mouseDragged). if !right && self @@ -9981,6 +10429,24 @@ impl Game<'_> { self.inventory_press(right); } } + /// A click on the search tab's box (`EditBox.onClick`). + fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool { + if !self.creative_screen.tab().search { + return false; + } + let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0); + let (mx, my) = self.cursor; + if mx < x || my < y || mx >= x + w || my >= y + h { + return false; + } + let shift = self.has_shift_down(); + let search = &mut self.creative_screen.search; + search.x = x; + search.y = y; + search.width = w; + search.click(&self.gui.atlas, mx, shift, false); + true + } /// Copy the live player and world state into the `Level` that gets saved. pub(super) fn sync_level(&mut self) { // Level.write saves the game rules (game_rules.dat) with the rest. @@ -10100,6 +10566,7 @@ impl Game<'_> { } /// Save the level in place: its changed chunks, its entities and its raids. fn save_level(&mut self, wait: bool) -> Result<()> { + self.save.save_forced_chunks(&self.streaming.forced)?; self.streaming.save(&mut self.world, wait); let keys: Vec<_> = self.world.modified.drain().collect(); let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k))); @@ -10299,8 +10766,6 @@ impl Game<'_> { Vec::new() }; let mut hud = horizon_client::spans::time("hud", || self.hud(size)); - // Map textures changed since the last drawn frame (game/maps.rs). - hud.patches.append(&mut self.map_patches); // LevelLoadingScreen (Reason.OTHER) draws the title's panorama. let title = self.title.is_some() || self.loading_reason() == Some(horizon_client::level_loading::Reason::Other); @@ -10402,6 +10867,10 @@ impl Game<'_> { } else { self.shader_pack_inputs(camera, alpha as f32) }; + // Map textures and font glyphs changed while building this frame's + // geometry (game/maps.rs, world text) upload with this batch; draining + // map_patches any earlier leaves them a frame behind. + hud.patches.append(&mut self.map_patches); let entity_spans = std::mem::take(self.entity_spans.get_mut()); let frame = Frame { weather: &weather, @@ -11017,11 +11486,7 @@ impl Game<'_> { bytes += size; } } - for c in unloaded { - for sy in 0..(HEIGHT / 16) { - renderer.remove((c.x, sy, c.z)); - } - } + renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z))); if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) { let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info); lod.set_near_loading(self.streaming.busy()); @@ -11492,16 +11957,17 @@ impl ApplicationHandler for Game<'_> { } self.extras_mouse(button, pressed); self.mod_mouse_key(button, pressed); - match self.settings.keys.translate_mouse(button) { - Some(Err(b)) => self.play_mouse(b, pressed)?, - Some(Ok(k)) if pressed => { - self.keys.insert(k); - self.key_pressed(k)?; + for mapped in self.settings.keys.translate_mouse_all(button) { + match mapped { + Err(b) => self.play_mouse(b, pressed)?, + Ok(k) if pressed => { + self.keys.insert(k); + self.key_pressed(k)?; + } + Ok(k) => { + self.keys.remove(&k); + } } - Some(Ok(k)) => { - self.keys.remove(&k); - } - None => {} } } Screen::Inventory | Screen::Creative @@ -11628,8 +12094,17 @@ impl ApplicationHandler for Game<'_> { // The hovered slot took the key (`ignoreTextInput`). } else if let Some(shortcut) = self.edit_shortcut(&event) { self.creative_search_shortcut(shortcut); - } else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) { - self.creative_search_backspace(self.has_control_down()); + } else if let PhysicalKey::Code(key) = event.physical_key { + // EditBox.keyPressed: arrows, Home, End, Delete and Backspace. + let before = self.creative_screen.search.value.clone(); + let (ctrl, shift) = (self.has_control_down(), self.has_shift_down()); + if self.creative_screen.search.key(key, ctrl, shift) { + if self.creative_screen.search.value != before { + self.creative_screen.scroll = 0.0; + } + } else { + self.creative_search_edit(event.text.as_deref()); + } } else { self.creative_search_edit(event.text.as_deref()); } @@ -11650,6 +12125,9 @@ impl ApplicationHandler for Game<'_> { // The anvil rename box takes every key but Escape (AnvilScreen.keyPressed). if let Some(shortcut) = self.edit_shortcut(&event) { self.station_shortcut(shortcut); + } else if let PhysicalKey::Code(key) = event.physical_key + && self.station_key(key) + { } else { let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace); self.station_text(event.text.as_deref(), backspace); @@ -11737,18 +12215,24 @@ impl ApplicationHandler for Game<'_> { { self.smooth_camera = !self.smooth_camera; } - // Key binds: act as the default key of the bound mapping. - let key = match self.settings.keys.translate(key) { - Some(Ok(k)) => k, - Some(Err(button)) => { - if self.screen == Screen::Playing && !event.repeat { - self.play_mouse(button, event.state == ElementState::Pressed)?; + // Key binds: act as the default key of every mapping bound + // to it (KeyMapping.set drives them all). + for mapped in self.settings.keys.translate_all(key) { + match mapped { + Ok(k) => { + self.mapped_key( + k, + event.state == ElementState::Pressed, + event.repeat, + )?; + } + Err(button) => { + if self.screen == Screen::Playing && !event.repeat { + self.play_mouse(button, event.state == ElementState::Pressed)?; + } } - return Ok(()); } - None => return Ok(()), - }; - self.mapped_key(key, event.state == ElementState::Pressed, event.repeat)?; + } } WindowEvent::RedrawRequested => { self.observe_fullscreen(); @@ -12162,6 +12646,7 @@ impl<'a> Game<'a> { last_input: now, pad: Default::default(), frames: 0, + item_level_cache: Default::default(), world_frames: 0, fps: 0.0, fps_frames: 0, @@ -12266,6 +12751,7 @@ impl<'a> Game<'a> { map_frame_model: prepared.map_frame_model, glow_map_frame_model: prepared.glow_map_frame_model, armor_stands: Vec::new(), + cushions: Vec::new(), others: Vec::new(), armor_stand_assets: prepared.armor_stand_assets, waypoint_styles: prepared.waypoint_styles, @@ -12275,6 +12761,7 @@ impl<'a> Game<'a> { boats: Vec::new(), boat_models: prepared.boat_models, crystal_model: prepared.crystal_model, + cushion_model: prepared.cushion_model, carts: Vec::new(), jukebox_sounds: HashMap::new(), bolts: Vec::new(), @@ -12361,6 +12848,7 @@ impl<'a> Game<'a> { lan_events: Vec::new(), lan_block_events: Vec::new(), lan_entity_events: Vec::new(), + lan_critical_hits: Vec::new(), lan_global_events: Vec::new(), lid_openers: HashMap::new(), lan_acting: None, @@ -12412,6 +12900,7 @@ struct GameCpuResources { boat_models: boats::BoatModels, cart_models: minecarts::CartModels, crystal_model: Option, + cushion_model: Option, dragon_model: Option, arrow_model: Option, trident_model: Option, @@ -12432,6 +12921,7 @@ impl GameCpuResources { boat_models: boats::BoatModels::load(document.as_ref()), cart_models: minecarts::CartModels::load(document.as_ref()), crystal_model: model("minecraft:end_crystal#main"), + cushion_model: model("minecraft:cushion#main"), dragon_model: model("minecraft:ender_dragon#main"), arrow_model: model("minecraft:arrow#main"), trident_model: model("minecraft:trident#main"), @@ -12991,6 +13481,14 @@ fn build_renderer( ) -> Result { let assets = &pack.render; let atlas = &gui.atlas; + // The new renderer's atlas texture starts from `HudAtlas.pixels`, which + // holds the textures but never a glyph rasterized at run time. The font's + // cache still answers with the rects it drew into the previous renderer's + // texture, so forget them: the glyphs that are drawn again are placed into + // this texture (as `ui_theme` does when its atlas changes). Without this, + // Chinese and Japanese menu labels go blank when the loading screen hands + // over to the game. + atlas.font.reset_cache(); let mut renderer = pollster::block_on(Renderer::new( window, offscreen, @@ -13037,6 +13535,12 @@ fn loading_renderer( Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height), None => (EMPTY_HUD.as_slice(), 1, 1), }; + // A glyph rasterized into an earlier texture is not in these pixels; the + // new texture gets it again when the character is drawn (see + // `build_renderer`). + if let Some(atlas) = atlas { + atlas.font.reset_cache(); + } let mut renderer = pollster::block_on(Renderer::new( window, offscreen, @@ -13773,7 +14277,7 @@ mod startup_cpu_tests { r#"{ "minecraft:end_crystal#main": {}, "minecraft:ender_dragon#main": {}, "minecraft:arrow#main": {}, "minecraft:trident#main": {}, - "minecraft:armor_stand#main": {} + "minecraft:armor_stand#main": {}, "minecraft:cushion#main": {} }"#, ) .unwrap(); @@ -13793,6 +14297,7 @@ mod startup_cpu_tests { .unwrap(); std::fs::remove_dir_all(&root).unwrap(); assert!(prepared.crystal_model.is_some()); + assert!(prepared.cushion_model.is_some()); assert!(prepared.dragon_model.is_some()); assert!(prepared.arrow_model.is_some()); assert!(prepared.trident_model.is_some()); diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 2686b85fd..fa9dbf3c6 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -300,6 +300,8 @@ struct Glide { pub(super) struct Agent { task: Option, glide: Option, + /// Control-fixture barrier: hold simulation and packet handling, not transport threads. + pub hold_server: bool, } pub(super) enum Handled { @@ -479,6 +481,8 @@ impl Game<'_> { "absorption": p.health.absorption(), // The hurt animation's ticks left (the camera's hurt tilt). "hurt_time": p.health.hurt_animation_time, + // LivingEntity.deathTime while dead (the camera's death roll). + "death_time": self.death_time(), "air": p.air, "food": p.health.food.food, "saturation": p.health.food.saturation, @@ -667,6 +671,7 @@ impl Game<'_> { }); if let Some(chat) = &self.ui.chat_screen { v["chat_input"] = json!(chat.input.value); + v["chat_suggestions"] = json!(chat.suggestions_visible()); } if self.title.is_some() { v["menu"] = self.title_value(); diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index f175af401..9a8c846c5 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -453,6 +453,11 @@ impl Game<'_> { } // ArmorStand.swapItem. match held { + // A player with infinite materials puts a copy of one on an empty + // slot and keeps the stack. + Some(h) if self.creative && current.is_none() => { + self.armor_stands[i].equipment[slot] = Some(ItemStack { count: 1, ..h }); + } Some(h) if h.count > 1 => { if current.is_some() { return true; diff --git a/crates/horizon-client/src/game/beacon.rs b/crates/horizon-client/src/game/beacon.rs index 24ead3a3d..97177f2b9 100644 --- a/crates/horizon-client/src/game/beacon.rs +++ b/crates/horizon-client/src/game/beacon.rs @@ -515,8 +515,7 @@ impl Game<'_> { if self.carried.is_some() { self.draw_carried(batch); } else if let Some(stack) = stack_tip { - let lines = self.stack_tooltip(&stack); - self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); + self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1); } else if let Some(key) = tooltip { let text = self.translate(&key, &key); self.draw_tooltip_lines( diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index 56f571bee..6f92d7f55 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -5,10 +5,14 @@ //! [`Game::block_anim`] and [`Game::chest_lid`]. use super::Game; use horizon_client::{ - block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker}, + block_entity_anim::{ + BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker, + }, block_entity_render::BlockAnim, collisions::{SceneCollisions, no_collision_empty}, containers::BlockEntity, + effects::EffectInstance, + mobs::{Damage, DamageKind}, particles::{ParticleOptions, ParticleType}, save::number, skins, @@ -16,6 +20,7 @@ use horizon_client::{ use horizon_core::{ geometry::{Aabb, Vec3}, position::BlockPos, + random::RandomSource, }; use horizon_world::nbt::Tag; @@ -218,7 +223,15 @@ impl Game<'_> { /// The client tickers of the loaded animated block entities, once per /// client tick (`ClientLevel.tickBlockEntities`). pub(super) fn tick_block_entity_anims(&mut self) { + // A server has no window, but its gateways and conduits still run + // their server tickers; a client of one only animates what it is sent. + if self.remote.is_none() { + self.tick_conduit_servers(); + } let Some(r) = &self.renderer else { + if self.remote.is_none() { + self.tick_gateway_servers(); + } return; }; // Sorted by position: the order they tick in, and a quick lookup. @@ -332,6 +345,7 @@ impl Game<'_> { } } Ticker::Conduit => { + let target = self.conduit_target(pos); let world = &self.world; let facts = self.res.facts(); let registry = self.pack.assets.registry; @@ -351,7 +365,7 @@ impl Game<'_> { time, is_water, is_frame, - None, + target, self.particles.random(), ); let nautilus = ParticleOptions::simple(ParticleType::Nautilus); @@ -378,6 +392,273 @@ impl Game<'_> { } self.be_anims.present = positions; } + /// `ConduitBlockEntity.serverTick` of every conduit in the chunks that + /// tick blocks, with or without a renderer. The conduits are looked for + /// when the shape is checked, every 40 ticks (they have no block entity + /// of their own here). + fn tick_conduit_servers(&mut self) { + if self.world.game_time % 40 == 0 { + self.find_conduits(); + } + let mut positions: Vec<[i32; 3]> = self.be_anims.conduit_servers.keys().copied().collect(); + positions.sort_unstable(); + for pos in positions { + self.tick_conduit_server(pos); + } + } + /// Sets `be_anims.conduit_servers` to the conduits in the + /// chunks that tick blocks, keeping the state of those already known. + fn find_conduits(&mut self) { + let levels = self.tickets.levels(); + let kinds = &self.be_anims.kinds; + let is_conduit = |id: u32| matches!(kinds.get(id as usize), Some(Some(Ticker::Conduit))); + let mut found = Vec::new(); + for (&(cx, cz), chunk) in &self.world.chunks { + if !levels.block_ticking_at(cx * 16, cz * 16) { + continue; + } + for (si, section) in chunk.sections.iter().enumerate() { + if !section.blocks.maybe_has(is_conduit) { + continue; + } + for i in 0..4096 { + if is_conduit(section.get(i)) { + found.push([ + cx * 16 + (i & 15) as i32, + horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32, + cz * 16 + ((i >> 4) & 15) as i32, + ]); + } + } + } + } + let mut old = std::mem::take(&mut self.be_anims.conduit_servers); + for pos in found { + let known = old.remove(&pos).unwrap_or_default(); + self.be_anims.conduit_servers.insert(pos, known); + } + } + /// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk + /// that ticks blocks: the shape check every 40 ticks with its sounds, the + /// power for players in range, the hurt for an enemy, then the ambient + /// sounds. + fn tick_conduit_server(&mut self, pos: [i32; 3]) { + let state = self.world.block(pos[0], pos[1], pos[2]).0; + if self.be_anims.ticker(state) != Some(Ticker::Conduit) { + // Broken since the chunks were searched. + self.be_anims.conduit_servers.remove(&pos); + return; + } + if !self.tickets.levels().block_ticking_at(pos[0], pos[2]) + || !self.border.contains(f64::from(pos[0]), f64::from(pos[2])) + { + return; + } + let time = self.world.game_time; + let mut conduit = self + .be_anims + .conduit_servers + .remove(&pos) + .unwrap_or_default(); + let check = { + let world = &self.world; + let facts = self.res.facts(); + let registry = self.pack.assets.registry; + let frames = self + .res + .items() + .block_tags + .get("minecraft:conduit_effect_block"); + // Level.isWaterAt: the fluid at the position is water. + let is_water = |p: [i32; 3]| { + facts.get(world.block(p[0], p[1], p[2])).fluid + == horizon_client::blocks::Fluid::Water + }; + let is_frame = |p: [i32; 3]| { + registry + .state(world.block(p[0], p[1], p[2])) + .is_some_and(|s| frames.is_some_and(|f| f.contains(&s.name))) + }; + conduit.check(pos, time, is_water, is_frame) + }; + let centre = [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]) + 0.5, + f64::from(pos[2]) + 0.5, + ]; + if let Some(check) = check { + if let Some(active) = check.toggled { + let sound = if active { + "minecraft:block.conduit.activate" + } else { + "minecraft:block.conduit.deactivate" + }; + self.audio.play(sound, Some(centre), 1.0, 1.0); + } + if let Some(frames) = check.frames { + self.conduit_apply_effects(pos, frames); + self.conduit_attack(&mut conduit, pos, frames >= 42); + } + } + let (ambient, short) = conduit.ambient(time, &mut self.world.random); + if ambient { + self.audio + .play("minecraft:block.conduit.ambient", Some(centre), 1.0, 1.0); + } + if short { + self.audio.play( + "minecraft:block.conduit.ambient_short", + Some(centre), + 1.0, + 1.0, + ); + } + self.be_anims.conduit_servers.insert(pos, conduit); + } + /// `ConduitBlockEntity.applyEffects`: conduit power for every player + /// in water or rain within `frames / 7 * 16` blocks. + fn conduit_apply_effects(&mut self, pos: [i32; 3], frames: usize) { + let range = (frames / 7 * 16) as i64; + self.for_each_player(|g| { + let Some(p) = g.player.as_ref().filter(|p| !p.health.dead_or_dying()) else { + return; + }; + if g.spectator() { + return; + } + let at = p.position; + let in_water = p.fluids.was_touching_water; + let top = at.y + f64::from(p.dimensions.height); + // BlockPos.closerThan: the squared distance under range squared. + let d = [at.x, at.y, at.z] + .map(|v| v.floor() as i64) + .iter() + .zip(pos) + .map(|(a, b)| (a - i64::from(b)).pow(2)) + .sum::(); + if d >= range * range || !(in_water || g.in_rain(at, top)) { + return; + } + let power = EffectInstance { + ambient: true, + ..EffectInstance::new("conduit_power", 260, 0) + }; + g.potions.effects.add(power); + }); + } + /// `Entity.isInRain`: rain at the block position or at the top of the box. + fn in_rain(&mut self, at: Vec3, top: f64) -> bool { + let p = BlockPos::new( + at.x.floor() as i32, + at.y.floor() as i32, + at.z.floor() as i32, + ); + let up = BlockPos::new(p.x, top.floor() as i32, p.z); + self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up)) + } + /// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts + /// (42 frame blocks) its target stays until it dies or leaves the 8 + /// blocks, else a random enemy in water or rain is chosen; the target + /// takes 4 magic damage. + fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) { + conduit.target = match conduit.target { + _ if !hunting => None, + None => self.conduit_new_target(pos), + Some(id) => self + .mobs + .iter() + .find(|m| m.id == id && !m.removed) + .filter(|m| { + let at = [m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i64); + m.alive() + && at + .iter() + .zip(pos) + .map(|(a, b)| (a - i64::from(b)).pow(2)) + .sum::() + < 64 + }) + .map(|m| m.id), + }; + let Some(m) = conduit + .target + .and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) + else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + m.damage(4.0, Damage::of(DamageKind::Magic)); + self.audio + .play("minecraft:block.conduit.attack.target", Some(at), 1.0, 1.0); + } + /// `ConduitBlockEntity.selectNewTarget`: one of the enemies in water or + /// rain whose box meets the conduit's block inflated by 8. + fn conduit_new_target(&mut self, pos: [i32; 3]) -> Option { + let [x, y, z] = pos.map(f64::from); + let area = Aabb::new( + Vec3::new(x - 8.0, y - 8.0, z - 8.0), + Vec3::new(x + 9.0, y + 9.0, z + 9.0), + ); + let near: Vec<(u32, bool, Vec3, f64)> = self + .mobs + .iter() + .filter(|m| m.hostile() && !m.removed && m.bounds().intersects(area)) + .map(|m| { + let top = m.position.y + f64::from(m.height()); + (m.id, m.fluids.in_water, m.position, top) + }) + .collect(); + let candidates: Vec = near + .into_iter() + .filter(|&(_, in_water, at, top)| in_water || self.in_rain(at, top)) + .map(|(id, ..)| id) + .collect(); + if candidates.is_empty() { + return None; + } + let pick = self + .world + .random + .next_int_bound(candidates.len() as i32) + .unwrap_or(0); + Some(candidates[pick as usize]) + } + /// The conduit's `destroyTarget` as the client draws it. + fn conduit_target(&self, pos: [i32; 3]) -> Option { + let id = self.be_anims.conduit_servers.get(&pos)?.target?; + let m = self.mobs.iter().find(|m| m.id == id && !m.removed)?; + Some(ConduitTarget { + position: [m.position.x, m.position.y, m.position.z], + eye_y: m.eye_y(), + width: m.width(), + height: m.height(), + }) + } + /// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the + /// gateways a server keeps a copy of, in block-ticking chunks + /// (`Level.tickBlockEntities`): the cooldown an entry started runs out, + /// and every 2400 ticks `triggerCooldown` sends its block event. + fn tick_gateway_servers(&mut self) { + let levels = self.tickets.levels(); + let mut positions: Vec<[i32; 3]> = self + .be_anims + .gateway_servers + .keys() + .copied() + .filter(|p| levels.block_ticking_at(p[0], p[2])) + .collect(); + positions.sort_unstable(); + for pos in positions { + let triggered = self + .be_anims + .gateway_servers + .get_mut(&pos) + .is_some_and(EndGateway::portal_tick); + if triggered { + self.block_entity_event(pos, 1, 0); + } + } + } /// `EndGatewayBlock.entityInside` on the server: whether the gateway at /// `pos` let the entity in (`triggerCooldown`, with its block event). pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool { diff --git a/crates/horizon-client/src/game/block_entity_ticks.rs b/crates/horizon-client/src/game/block_entity_ticks.rs index e48c8f398..2778bb531 100644 --- a/crates/horizon-client/src/game/block_entity_ticks.rs +++ b/crates/horizon-client/src/game/block_entity_ticks.rs @@ -10,6 +10,7 @@ use horizon_client::{ block_entities::{Pos, Ticking}, containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS}, }; +use horizon_core::position::BlockPos; use horizon_world::registry::BlockStateId; impl Game<'_> { @@ -56,6 +57,8 @@ impl Game<'_> { Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos), Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?, Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?, + Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos), + Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?, _ => {} } } @@ -92,7 +95,11 @@ impl Game<'_> { | "minecraft:vault" | "minecraft:beehive" | "minecraft:bee_nest" - | "minecraft:brewing_stand" => Ticking::Yes, + | "minecraft:brewing_stand" + | "minecraft:daylight_detector" + // Lit, cookTick; unlit, cooldownTick. + | "minecraft:campfire" + | "minecraft:soul_campfire" => Ticking::Yes, "minecraft:jukebox" => { let record = registry .state(state) @@ -113,6 +120,16 @@ impl Game<'_> { let levels = self.tickets.levels(); move |x, z| levels.block_ticking_at(x, z) } + /// `DaylightDetectorBlock.tickEntity` of the detector at `pos`. + fn tick_daylight_detector(&mut self, pos: Pos) { + // It updates every 20 game ticks (the behaviour checks this too). + if self.world.game_time % 20 != 0 { + return; + } + let at = BlockPos::new(pos.0, pos.1, pos.2); + self.with_level(|b, level, ticks| b.tick_daylight_detector(level, ticks, at)); + let _ = self.process_level_events(); + } /// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and /// its block's `lit` following it. fn tick_furnace(&mut self, pos: Pos) -> Result<()> { diff --git a/crates/horizon-client/src/game/boats.rs b/crates/horizon-client/src/game/boats.rs index df7158d9a..06dfab22d 100644 --- a/crates/horizon-client/src/game/boats.rs +++ b/crates/horizon-client/src/game/boats.rs @@ -55,7 +55,7 @@ pub(super) struct Boat { /// `ChestBoat.CONTAINER_SIZE`. pub(super) const CHEST_SLOTS: usize = 27; pub(super) const WIDTH: f64 = 1.375; -const HEIGHT: f64 = 0.5625; +pub(super) const HEIGHT: f64 = 0.5625; impl Boat { pub fn new(id: u32, wood: Arc, position: Vec3, yaw: f32) -> Self { Self { @@ -587,6 +587,9 @@ impl Game<'_> { } /// The player's tick while riding: steer, tick the boat, sit in it. pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> { + if self.tick_cushion_riding(keys) { + return Ok(()); + } let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else { if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) { return Ok(()); diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index 9af1da70c..2f38dffd9 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -17,7 +17,6 @@ use horizon_client::{ use horizon_core::position::BlockPos; use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S}; use horizon_world::level::flags as level_flags; -use std::time::Instant; use winit::{ event::{ElementState, KeyEvent}, keyboard::{KeyCode, PhysicalKey}, @@ -27,8 +26,24 @@ const BLACK: u32 = 0xFF00_0000; const PAGE_CHARS: usize = 1024; const MAX_PAGES: usize = 100; const LINES: usize = 14; -/// `BookSignScreen`'s title box. -const BOOK_TITLE: horizon_client::clipboard::FieldId = ("book.title", 0); + +fn book_title_box() -> ui::EditBox { + let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0); + edit.bordered = false; + edit.max_length = 15; + edit.text_color = BLACK; + edit.focused = true; + edit +} +fn book_page_box() -> ui::EditBox { + let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0); + edit.bordered = false; + edit.multiline = true; + edit.max_length = PAGE_CHARS; + edit.text_color = BLACK; + edit.focused = true; + edit +} pub(super) enum BookScreen { Edit { @@ -38,7 +53,10 @@ pub(super) enum BookScreen { page: usize, signing: bool, title: String, - opened: Instant, + /// BookSignScreen's title box (`EditBox` of 15). + title_edit: ui::EditBox, + /// BookEditScreen's page (`MultiLineEditBox`). + page_edit: ui::EditBox, }, View { pages: Vec, @@ -84,13 +102,16 @@ impl Game<'_> { if pages.is_empty() { pages.push(String::new()); } + let mut page_edit = book_page_box(); + page_edit.set_value(&pages[0]); BookScreen::Edit { slot: self.held_slot(), pages, page: 0, signing: false, title: String::new(), - opened: Instant::now(), + title_edit: book_title_box(), + page_edit, } } "minecraft:written_book" => BookScreen::View { @@ -239,34 +260,24 @@ impl Game<'_> { Some(KeyCode::PageDown) => self.turn_page(true), _ => {} }, - Some(BookScreen::Edit { - signing: true, - title, - .. - }) => match key { + Some(BookScreen::Edit { signing: true, .. }) => match key { Some(KeyCode::Escape) => { if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() { *signing = false; } } - Some(KeyCode::Backspace) => { - if !self.text_selection.take(BOOK_TITLE, title) { - title.pop(); - } - } Some(KeyCode::Enter | KeyCode::NumpadEnter) => { - if !title.trim().is_empty() { + let blank = self.book.as_ref().is_some_and( + |b| matches!(b, BookScreen::Edit { title, .. } if title.trim().is_empty()), + ); + if !blank { self.save_book(true); } } + Some(key) if self.book_title_key(key) => {} _ => { if let Some(typed) = text { - self.text_selection.take(BOOK_TITLE, title); - for c in typed.chars().filter(|c| !c.is_control()) { - if title.chars().count() < 15 { - title.push(c); - } - } + self.book_title_insert(typed); } } }, @@ -274,24 +285,13 @@ impl Game<'_> { Some(KeyCode::Escape) => self.save_book(false), Some(KeyCode::PageUp) => self.turn_page(false), Some(KeyCode::PageDown) => self.turn_page(true), - Some(KeyCode::Backspace) => { - if !self.take_page_selection() { - self.edit_page(|p| { - p.pop(); - }) - } - } - Some(KeyCode::Enter | KeyCode::NumpadEnter) => { - self.take_page_selection(); - self.type_into_page("\n") - } + Some(KeyCode::ArrowUp) => self.book_move_line(-1), + Some(KeyCode::ArrowDown) => self.book_move_line(1), + Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"), + Some(key) if self.book_page_key(key) => {} _ => { if let Some(typed) = text { - let typed: String = typed.chars().filter(|c| !c.is_control()).collect(); - if !typed.is_empty() { - self.take_page_selection(); - } - self.type_into_page(&typed); + self.page_insert(typed); } } }, @@ -302,99 +302,258 @@ impl Game<'_> { pub(super) fn book_type(&mut self, text: &str) { let text = text.replace("\\n", "\n"); match self.book.as_mut() { - Some(BookScreen::Edit { - signing: true, - title, - .. - }) => { - for c in text.chars().filter(|c| !c.is_control()) { - if title.chars().count() < 15 { - title.push(c); - } - } - } - Some(BookScreen::Edit { .. }) => self.type_into_page(&text), + Some(BookScreen::Edit { signing: true, .. }) => self.book_title_insert(&text), + Some(BookScreen::Edit { .. }) => self.page_insert(&text), _ => {} } } - /// Before typing on a page: Select All's selection is cleared (true). - fn take_page_selection(&mut self) -> bool { - match self.book.as_mut() { - Some(BookScreen::Edit { pages, page, .. }) => self - .text_selection - .take(("book.page", *page), &mut pages[*page]), - _ => false, + /// `BookSignScreen.keyPressed` on the title box. + fn book_title_key(&mut self, key: KeyCode) -> bool { + let (ctrl, shift) = (self.has_control_down(), self.has_shift_down()); + let Some(BookScreen::Edit { + title, title_edit, .. + }) = self.book.as_mut() + else { + return false; + }; + if !title_edit.key(key, ctrl, shift) { + return false; } + title.clone_from(&title_edit.value); + true } - /// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else - /// the page (`MultilineTextField.insertText`: line breaks kept, cut to - /// the 1024 characters left, rejected when it overflows the 14 lines). - pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) { - let font = &self.gui.atlas.font; - match self.book.as_mut() { - Some(BookScreen::Edit { - signing: true, - title, - .. - }) => { - Self::append_shortcut( - &mut self.text_selection, - &mut self.clipboard, - BOOK_TITLE, - title, - shortcut, - 15, - ); - } - Some(BookScreen::Edit { pages, page, .. }) => { - // truncateInsertionText counts the whole value, selection included. - let room = PAGE_CHARS.saturating_sub(pages[*page].chars().count()); - self.text_selection.apply( - ("book.page", *page), - &mut pages[*page], - shortcut, - &mut self.clipboard, - |value, pasted| { - let text: String = horizon_client::clipboard::filter_text(pasted, true) - .chars() - .take(room) - .collect(); - let next = format!("{value}{text}"); - font.split(&Text::literal(&next), 114.0).len() <= LINES && { - *value = next; - true - } - }, - ); - } - _ => {} - } - } - fn edit_page(&mut self, f: impl FnOnce(&mut String)) { - if let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_mut() { - f(&mut pages[*page]); - } - } - /// MultiLineEditBox: characters that keep the page within 14 lines and 1024 characters. - fn type_into_page(&mut self, typed: &str) { - let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_ref() else { + fn book_title_insert(&mut self, text: &str) { + let Some(BookScreen::Edit { + title, title_edit, .. + }) = self.book.as_mut() + else { return; }; - let mut text = pages[*page].clone(); - for c in typed.chars() { - let mut next = text.clone(); - next.push(c); - if next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES { - text = next; + title_edit.insert_text(text); + title.clone_from(&title_edit.value); + } + /// `BookEditScreen.keyPressed` on the page, apart from the line moves. + fn book_page_key(&mut self, key: KeyCode) -> bool { + let (ctrl, shift) = (self.has_control_down(), self.has_shift_down()); + let before = match self.book.as_ref() { + Some(BookScreen::Edit { page_edit, .. }) => Some(( + page_edit.value.clone(), + page_edit.cursor, + page_edit.highlight, + )), + _ => return false, + }; + let handled = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() { + page_edit.key(key, ctrl, shift) + } else { + false + }; + if handled { + self.page_keep(before); + } + handled + } + /// One character at a time, so a page stops at the last character that fits. + fn page_insert(&mut self, text: &str) { + for c in text.chars() { + self.page_change(|edit| edit.insert_text(&c.to_string())); + } + } + /// Ctrl+A/C/V/X: the title box while signing, else the page. + pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) { + let signing = matches!(self.book, Some(BookScreen::Edit { signing: true, .. })); + if signing { + if let Some(BookScreen::Edit { + title, title_edit, .. + }) = self.book.as_mut() + { + title_edit.shortcut(shortcut, &mut self.clipboard); + title.clone_from(&title_edit.value); + } + return; + } + let before = match self.book.as_ref() { + Some(BookScreen::Edit { page_edit, .. }) => Some(( + page_edit.value.clone(), + page_edit.cursor, + page_edit.highlight, + )), + _ => None, + }; + if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() { + page_edit.shortcut(shortcut, &mut self.clipboard); + } + self.page_keep(before); + } + /// Edit the page box, then keep it only within 14 lines and 1024 characters. + fn page_change(&mut self, f: impl FnOnce(&mut ui::EditBox)) -> bool { + let before = match self.book.as_ref() { + Some(BookScreen::Edit { page_edit, .. }) => Some(( + page_edit.value.clone(), + page_edit.cursor, + page_edit.highlight, + )), + _ => return false, + }; + let ran = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() { + f(page_edit); + true + } else { + false + }; + ran && self.page_keep(before) + } + /// Write the page, or put the previous text back when it no longer fits. + fn page_keep(&mut self, before: Option<(String, usize, usize)>) -> bool { + let next = match self.book.as_ref() { + Some(BookScreen::Edit { page_edit, .. }) => page_edit.value.clone(), + _ => return false, + }; + let ok = next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES; + let Some(BookScreen::Edit { + pages, + page, + page_edit, + .. + }) = self.book.as_mut() + else { + return false; + }; + if ok { + pages[*page].clone_from(&page_edit.value); + } else if let Some((value, cursor, highlight)) = before { + page_edit.value = value; + page_edit.cursor = cursor; + page_edit.highlight = highlight; + } + ok + } + /// ArrowUp and ArrowDown move a visual line, keeping the column. + fn book_move_line(&mut self, dir: isize) { + let shift = self.has_shift_down(); + let (text, cursor) = match self.book.as_ref() { + Some(BookScreen::Edit { + pages, + page, + page_edit, + .. + }) => (pages[*page].clone(), page_edit.cursor), + _ => return, + }; + let rows = self.page_rows(&text); + let (x, line) = self.caret_on(&text, &rows, cursor); + let next = (line as isize + dir).clamp(0, rows.len() as isize - 1) as usize; + let (start, end) = rows[next]; + let chars: Vec = text.chars().collect(); + let slice: String = chars[start..end].iter().collect(); + let pos = start + self.gui.atlas.font.fit(&slice, x); + if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() { + page_edit.set_cursor(pos, shift); + } + } + /// Wrapped rows of a page as character ranges, the same breaks as `font.split`. + fn page_rows(&self, page: &str) -> Vec<(usize, usize)> { + let font = &self.gui.atlas.font; + let chars: Vec = page.chars().collect(); + let mut rows = Vec::new(); + let mut start = 0; + while start < chars.len() { + let mut end = chars.len(); + let mut next = chars.len(); + let mut last_space = None; + for i in start..chars.len() { + if chars[i] == '\n' { + end = i; + next = i + 1; + break; + } + if chars[i] == ' ' { + last_space = Some(i); + } + let slice: String = chars[start..=i].iter().collect(); + if font.width(&slice) > 114.0 { + match last_space { + Some(s) if s > start => { + end = s; + next = s + 1; + } + _ => { + end = i.max(start + 1); + next = end; + } + } + break; + } + } + rows.push((start, end)); + start = next; + if start == chars.len() && chars.last() == Some(&'\n') { + rows.push((chars.len(), chars.len())); } } - self.edit_page(|p| *p = text); + if rows.is_empty() { + rows.push((0, 0)); + } + rows + } + /// GUI x of the caret and the row it sits on. + fn caret_on(&self, page: &str, rows: &[(usize, usize)], cursor: usize) -> (f32, usize) { + let chars: Vec = page.chars().collect(); + for (i, &(start, end)) in rows.iter().enumerate() { + if cursor <= end || i + 1 == rows.len() { + let take = cursor.saturating_sub(start).min(end.saturating_sub(start)); + let prefix: String = chars.iter().skip(start).take(take).collect(); + return (self.gui.atlas.font.width(&prefix), i); + } + } + (0.0, 0) + } + fn page_index_at(&self, page: &str, local_x: f32, local_y: f32) -> usize { + let rows = self.page_rows(page); + let line = (local_y / 9.0).floor().max(0.0) as usize; + let line = line.min(rows.len().saturating_sub(1)); + let (start, end) = rows[line]; + let chars: Vec = page.chars().collect(); + let slice: String = chars[start..end].iter().collect(); + start + self.gui.atlas.font.fit(&slice, local_x.max(0.0)) + } + /// A click on the centred title (`EditBox.onClick`). + fn book_title_click(&mut self, field_x: f32, mx: f32) { + let shift = self.has_shift_down(); + let Some(BookScreen::Edit { + title, title_edit, .. + }) = self.book.as_mut() + else { + return; + }; + let tx = field_x + ((114.0 - self.gui.atlas.font.width(title)) / 2.0).floor(); + let rel = (mx - tx).max(0.0); + let pos = self.gui.atlas.font.fit(title, rel); + title_edit.set_cursor(pos, shift); + } + /// A click on the open page. + fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) { + let shift = self.has_shift_down(); + let text = match self.book.as_ref() { + Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(), + _ => return, + }; + let pos = self.page_index_at(&text, mx - origin_x, my - origin_y); + if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() { + page_edit.set_cursor(pos, shift); + } } /// The page buttons (`pageForward` adds a page while editing). fn turn_page(&mut self, forward: bool) { let mut lectern_page = None; match self.book.as_mut() { - Some(BookScreen::Edit { pages, page, .. }) => { + Some(BookScreen::Edit { + pages, + page, + page_edit, + .. + }) => { + let before = *page; if forward { if *page + 1 >= pages.len() && pages.len() < MAX_PAGES { pages.push(String::new()); @@ -405,6 +564,10 @@ impl Game<'_> { } else if *page > 0 { *page -= 1; } + if *page != before { + let text = pages[*page].clone(); + page_edit.set_value(&text); + } } Some(BookScreen::View { lectern: Some(key), .. @@ -764,6 +927,8 @@ impl Game<'_> { if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() { *signing = false; } + } else if inside(left + 36.0, 50.0, 114.0, 9.0) { + self.book_title_click(left + 36.0, mx); } } Some(BookScreen::Edit { page, .. }) => { @@ -780,6 +945,8 @@ impl Game<'_> { } else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) { click(self); self.save_book(false); + } else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) { + self.book_page_click(left + 36.0, 32.0, mx, my); } } Some(BookScreen::View { @@ -865,7 +1032,7 @@ impl Game<'_> { BookScreen::Edit { signing: true, title, - opened, + title_edit, .. } => { let edit = tr("book.editTitle", "Enter Book Title:"); @@ -881,19 +1048,35 @@ impl Game<'_> { // The title box: an unbordered, centred EditBox of 114 with // black text and no shadow; the value alone is centred, the // cursor follows it. + let value = &title_edit.value; let x = left + 36.0; - let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor(); - horizon_client::ui::append_field( - batch, - atlas, - title, - (tx, 50.0, x + 114.0 - tx), - (BLACK, false), - 15, - self.text_selection.covers(BOOK_TITLE, title), - true, - opened.elapsed().as_millis(), - ); + let tx = x + ((114.0 - HudBatch::text_width(atlas, value)) / 2.0).floor(); + batch.text(atlas, value, tx, 50.0, BLACK, false); + if title_edit.cursor != title_edit.highlight { + let (a, b) = ( + title_edit.cursor.min(title_edit.highlight), + title_edit.cursor.max(title_edit.highlight), + ); + let pre: String = value.chars().take(a).collect(); + let sel: String = value.chars().skip(a).take(b - a).collect(); + let sx = tx + HudBatch::text_width(atlas, &pre); + batch.text_highlight( + atlas, + (sx, 49.0, HudBatch::text_width(atlas, &sel), 11.0), + true, + ); + } + if title_edit.cursor_visible() { + let pre: String = value.chars().take(title_edit.cursor).collect(); + batch.fill( + atlas, + tx + HudBatch::text_width(atlas, &pre), + 49.0, + 1.0, + 9.0, + BLACK, + ); + } let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME); let tw = HudBatch::text_width(atlas, &by); batch.text( @@ -941,12 +1124,12 @@ impl Game<'_> { BookScreen::Edit { pages, page, - opened, + page_edit, .. } => { indicator(batch, *page, pages.len()); - let blink = (opened.elapsed().as_millis() / 300) % 2 == 0; - let lines = self.book_lines(&pages[*page]); + let text = &pages[*page]; + let lines = self.book_lines(text); for (i, line) in lines.iter().take(LINES).enumerate() { batch.text_styled( atlas, @@ -957,27 +1140,39 @@ impl Game<'_> { false, ); } - if blink { - // The cursor after the last character. - let last = lines - .last() - .map(|l| atlas.font.width_text(l)) - .unwrap_or(0.0); - let row = lines.len().saturating_sub(1) as f32 - + if pages[*page].ends_with('\n') { - 1.0 - } else { - 0.0 - }; - let x = if pages[*page].ends_with('\n') { - 0.0 - } else { - last - }; + let rows = self.page_rows(text); + let (sel_a, sel_b) = ( + page_edit.cursor.min(page_edit.highlight), + page_edit.cursor.max(page_edit.highlight), + ); + if sel_a != sel_b { + let chars: Vec = text.chars().collect(); + for (i, &(start, end)) in rows.iter().enumerate().take(LINES) { + let from = sel_a.max(start); + let to = sel_b.min(end); + if from >= to { + continue; + } + let pre: String = chars[start..from].iter().collect(); + let sel: String = chars[from..to].iter().collect(); + batch.text_highlight( + atlas, + ( + left + 36.0 + atlas.font.width(&pre), + top + 29.0 + i as f32 * 9.0, + atlas.font.width(&sel), + 9.0, + ), + true, + ); + } + } + if page_edit.cursor_visible() { + let (x, row) = self.caret_on(text, &rows, page_edit.cursor); batch.fill( atlas, left + 36.0 + x, - top + 29.0 + row * 9.0, + top + 29.0 + row as f32 * 9.0, 1.0, 9.0, BLACK, diff --git a/crates/horizon-client/src/game/campfire.rs b/crates/horizon-client/src/game/campfire.rs new file mode 100644 index 000000000..c388b7561 --- /dev/null +++ b/crates/horizon-client/src/game/campfire.rs @@ -0,0 +1,190 @@ +//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`) +//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The +//! items drawn on the fire are `campfire_geometry`'s; a server's clients get +//! them through the block entity's update tag (`lan::chunks::entity_tag`). +use super::Game; +use horizon_assets::item_model::DisplayContext; +use horizon_client::{ + Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity, + items::ItemStack, mesher::Vertex, mobs::Who, +}; +use horizon_core::position::BlockPos; + +/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`). +const KIND: &str = "minecraft:campfire_cooking"; + +fn is_campfire(name: &str) -> bool { + name == "minecraft:campfire" || name == "minecraft:soul_campfire" +} + +impl Game<'_> { + /// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's + /// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food + /// goes into the first free slot (`placeFood`), and the use is consumed + /// whether or not there was room. A client of a server leaves the placing + /// to the server. Whether the food went in; None when this isn't a + /// campfire use, so the item's own use runs. + pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result> { + let state = self.world.block(pos[0], pos[1], pos[2]); + if !is_campfire(&self.res.facts().get(state).name) { + return Ok(None); + } + let Some(held) = self.held().cloned() else { + return Ok(None); + }; + let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else { + return Ok(None); + }; + // A client of a server predicts the placing; the server decides. + if self.remote.is_some() { + return Ok(Some(true)); + } + let key = (pos[0], pos[1], pos[2]); + let placed = match self.world.block_entities.get_mut(&key) { + Some(BlockEntity::Campfire(c)) => { + let mut one = held.clone(); + c.place_food(&mut one, time) + } + _ => false, + }; + if placed { + // ItemStack.consumeAndReturn: creative keeps the item. + self.consume_held(); + self.stats.custom("interact_with_campfire", 1); + self.post_block_game_event( + "minecraft:block_change", + pos, + Some(Who::Player), + Some(state), + ); + self.mark_block_entity_updated(key); + } + Ok(Some(placed)) + } + + /// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an + /// unlit one. A finished item's result (the recipe's, or the item itself + /// when no recipe takes it any more) drops at the block's corner + /// (`Containers.dropItemStack` at its integer position), then the clients + /// see the slot empty and a `block_change` goes out. + pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> { + let state = self.world.block(pos.0, pos.1, pos.2); + let lit = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("lit")) + .is_some_and(|v| v == "true"); + let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else { + return Ok(()); + }; + // BlockEntity.setChanged: a timer that moved is saved with the + // chunk (no update to the clients, who don't see the timers). + let changed = if lit { + c.items.iter().any(Option::is_some) + } else { + c.cooldown_tick() + }; + if changed { + self.world.modified.insert((pos.0 >> 4, pos.2 >> 4)); + } + if !lit { + return Ok(()); + } + let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else { + return Ok(()); + }; + let done = c.cook_tick(); + for (_, item) in done { + let result = self + .res + .recipes() + .cook(KIND, &item) + .map_or_else(|| item.clone(), |r| r.result.clone()); + self.campfire_drop(pos, result); + self.mark_block_entity_updated(pos); + self.post_block_game_event( + "minecraft:block_change", + [pos.0, pos.1, pos.2], + None, + Some(state), + ); + } + Ok(()) + } + + /// `CampfireRenderer`: each cooking item lies on the fire, slot `i` + /// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat + /// (90° about X), at a corner and three eighths of its size, drawn as a + /// FIXED item seeded by the block's position plus the slot. + pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec) { + for (key, entity) in &self.world.block_entities { + let BlockEntity::Campfire(c) = entity else { + continue; + }; + if c.items.iter().all(Option::is_none) { + continue; + } + let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2)); + if at.distance_squared(camera) >= 64.0 * 64.0 { + continue; + } + // Direction.get2DDataValue: south 0, west 1, north 2, east 3. + let state = self.world.block(key.0, key.1, key.2); + let facing = match self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("facing")) + .map(String::as_str) + { + Some("west") => 1, + Some("north") => 2, + Some("east") => 3, + _ => 0, + }; + let light = vertex_light(self.world.light(key.0, key.1, key.2)); + let packed = BlockPos::new(key.0, key.1, key.2).pack(); + for (i, stack) in c.items.iter().enumerate() { + let Some(stack) = stack else { + continue; + }; + let seed = (packed as i32).wrapping_add(i as i32); + let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed); + if item.is_empty() { + continue; + } + // Direction.toYRot: 90° per 2D step from south. + let yaw = -(((i + facing) % 4) as f32 * 90.0); + let pose = glam::Mat4::from_translation( + (at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5), + ) * glam::Mat4::from_rotation_y(yaw.to_radians()) + * glam::Mat4::from_rotation_x(90f32.to_radians()) + * glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0)) + * glam::Mat4::from_scale(glam::Vec3::splat(0.375)); + self.submit_item_world(&item, pose, light, out); + } + } + } + + /// `Containers.dropItemStack(level, x, y, z, stack)` at the block's + /// integer position. + fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) { + let at = BlockPos::new(pos.0, pos.1, pos.2); + for piece in + horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count) + { + self.spawn_item( + ItemStack { + count: piece.count, + ..stack.clone() + }, + piece.pos, + piece.motion, + 0, + ); + } + } +} diff --git a/crates/horizon-client/src/game/campfire_douse.rs b/crates/horizon-client/src/game/campfire_douse.rs new file mode 100644 index 000000000..04accb2d3 --- /dev/null +++ b/crates/horizon-client/src/game/campfire_douse.rs @@ -0,0 +1,53 @@ +//! `CampfireBlock.useItemOn`: an item in `#minecraft:douses_campfires` (the +//! shovels) put on a lit campfire puts it out. +use super::Game; +use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack}; + +impl Game<'_> { + /// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block + /// at `pos` clicked: true when it doused a lit campfire (SUCCESS). + pub(super) fn use_campfire_douse( + &mut self, + pos: [i32; 3], + held: Option<&ItemStack>, + ) -> Result { + let Some(tool) = held.filter(|s| { + self.res + .items() + .item_tags + .get("minecraft:douses_campfires") + .is_some_and(|members| members.contains(&*s.item)) + }) else { + return Ok(false); + }; + let registry = self.pack.assets.registry; + let Some(mut state) = registry + .state(self.world.block(pos[0], pos[1], pos[2])) + .filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire")) + .filter(|s| s.properties.get("lit").map(String::as_str) == Some("true")) + .cloned() + else { + return Ok(false); + }; + // Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player. + self.level_event_at(1009, pos, 0); + // CampfireBlock.douse: the acting client's own smoke (twenty puffs), + // then the block change game event from the player. + if self.lan_acting.is_none() { + let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true"); + for _ in 0..20 { + make_campfire_smoke(&mut self.particles, pos, signal, true); + } + } + self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), None); + state.properties.insert("lit".into(), "false".into()); + if let Ok(unlit) = registry.resolve(&state) { + self.level_set(pos, unlit)?; + } + let used = tool.clone(); + self.damage_held(1); + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, &used); + Ok(true) + } +} diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 20b47dca7..03174c009 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -464,7 +464,28 @@ impl Game<'_> { return; } // The Chat option's Hidden level takes the server's messages too. - if !self.ui.chat.shows(ChatSource::Server) { + self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server); + } + /// A player's chat line on the window's own chat: a LAN guest's message + /// while the host runs in-frame (`HostEvent::Chat`). `ChatListener` + /// checks `canReceivePlayerMessages` for these, so Commands Only and + /// Hidden drop them; command feedback (`/list`, `/tp`) is a system + /// message and takes the system-message permission instead. + pub(super) fn player_message(&mut self, text: Text) { + if let Some(capture) = self.chat_capture.as_mut() { + capture.push(text); + return; + } + // A server's client receives the line as a packet (PlayerChat or + // DisguisedChat) and filters it there. + if self.remote.is_none() { + self.local_chat_line(text, None, ChatSource::Player); + } + } + /// A line on the window's own chat when the Chat option lets `source` + /// through; running mods hear it. + fn local_chat_line(&mut self, text: Text, indicator: Option, source: ChatSource) { + if !self.ui.chat.shows(source) { return; } let focused = self.screen == Screen::Chat; @@ -472,8 +493,8 @@ impl Game<'_> { self.ui.chat.add( &self.gui.atlas, text, - Some(SYSTEM_INDICATOR), - ChatSource::Server, + indicator, + source, self.ticks, focused, ); @@ -761,6 +782,7 @@ impl Game<'_> { max_snow_accumulation_height: r.int("max_snow_accumulation_height"), spawn_mobs: r.bool("spawn_mobs"), spawn_monsters: r.bool("spawn_monsters"), + fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"), } } /// Apply rules mirrored in other state (natural regeneration, difficulty). diff --git a/crates/horizon-client/src/game/clipboard.rs b/crates/horizon-client/src/game/clipboard.rs index c87aa3681..6e81bf3ee 100644 --- a/crates/horizon-client/src/game/clipboard.rs +++ b/crates/horizon-client/src/game/clipboard.rs @@ -63,8 +63,8 @@ impl Game<'_> { } } } - /// Typing or a backspace in a field of the title screens: in the server - /// screens' append-only fields Select All's selection is replaced first. + /// Typing or a backspace in a title-screen field. A field that still only + /// appends replaces a Select All selection before the edit. pub(super) fn title_edit(&mut self, typed: Option<&str>, backspace: bool) { let Some(title) = self.title.as_mut() else { return; @@ -86,42 +86,31 @@ impl Game<'_> { } /// Shortcuts in the creative inventory's search box (an `EditBox` of 50). pub(super) fn creative_search_shortcut(&mut self, shortcut: EditShortcut) { - if Self::append_shortcut( - &mut self.text_selection, - &mut self.clipboard, - CREATIVE_SEARCH, - &mut self.creative_screen.search, - shortcut, - CREATIVE_SEARCH_CHARS, - ) { + if self + .creative_screen + .search + .shortcut(shortcut, &mut self.clipboard) + { self.creative_screen.scroll = 0.0; } } /// A backspace in the creative search box: with Ctrl, the whole word /// before the caret (`EditBox.deleteWords(-1)`), otherwise one character. - /// A Select All selection is what it deletes, as in `EditBox.keyPressed`. + /// A selection is what it deletes, as in `EditBox.keyPressed`. pub(super) fn creative_search_backspace(&mut self, ctrl: bool) { - let search = &mut self.creative_screen.search; - if !self.text_selection.take(CREATIVE_SEARCH, search) { - if ctrl { - horizon_client::ui::delete_words(search, -1); - } else { - search.pop(); - } - } + self.creative_screen + .search + .key(KeyCode::Backspace, ctrl, false); self.creative_screen.scroll = 0.0; } /// Typing in the creative search box. pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) { - let search = &mut self.creative_screen.search; - self.text_selection.take(CREATIVE_SEARCH, search); if let Some(text) = typed { - clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS); + self.creative_screen.search.insert_text(text); + self.creative_screen.scroll = 0.0; } - self.creative_screen.scroll = 0.0; } } /// `CreativeModeInventoryScreen`'s search box: `setMaxLength(50)`. pub(super) const CREATIVE_SEARCH_CHARS: usize = 50; -pub(super) const CREATIVE_SEARCH: horizon_client::clipboard::FieldId = ("creative.search", 0); diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 227aa0332..6e1d5d82b 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -31,6 +31,10 @@ pub(super) struct CombatTracker { /// The mob exploding now (`ServerExplosion.source`): its team's friendly /// fire decides whom the blast may hurt. pub(super) explosion_source: Option, + /// Charged creepers whose blast just went off and that have not dropped + /// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list, + /// but the mobs they killed still ask for their loot. + pub(super) charged_exploders: Vec, /// The player's fall distance before this tick's movement (as of the /// end of the last one). fall_before: f64, @@ -52,11 +56,16 @@ impl Game<'_> { /// Remember who deals the next `damage_type` damage to the player. pub(super) fn note_attacker(&mut self, damage_type: &str, mob: u32) { if let Some(name) = self.mob_display_name(mob) { - self.combat - .pending - .push((damage_type.to_owned(), (mob, name))); + self.note_named_attacker(damage_type, mob, name); } } + /// `note_attacker` for an entity that is not one of `self.mobs` (the + /// ender dragon). + pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) { + self.combat + .pending + .push((damage_type.to_owned(), (id, name))); + } /// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the /// source's `DamageEffects` (`Player.getHurtSound`). pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) { diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 76e10cb57..22041c3b2 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -85,16 +85,16 @@ fn format_double(v: f64) -> String { impl Game<'_> { /// Commands.performCommand for a line without its slash. - pub(super) fn run_command(&mut self, command: &str) { + pub(super) fn run_command(&mut self, command: &str) -> bool { if self.mod_command(command) { - return; + return true; } let d = vanilla::game_dispatcher(); let parse = d.parse(command, &self.ui.registries); let result = match parse.executable() { Err(e) => { self.syntax_failure(&e, command); - return; + return false; } Ok((ctx, _)) => { let ctx = ctx.clone(); @@ -103,6 +103,7 @@ impl Game<'_> { self.run_chain(&root, &ctx) } }; + let ok = result.is_ok(); if let Err(e) = result { if e.input.is_some() { self.syntax_failure(&e, command); @@ -114,6 +115,20 @@ impl Game<'_> { if self.player.is_some() { self.check_inventory_trigger(); } + ok + } + /// `run_command` with its output suppressed + /// (`CommandSourceStack.withSuppressedOutput`): the player's own source, + /// rights and checks, no feedback and no failure in the chat. + pub(super) fn run_command_quietly(&mut self, command: &str) -> bool { + let quiet = execute::Source { + silent: true, + ..self.player_source() + }; + let outer = self.command_source.replace(quiet); + let ok = self.run_command(command); + self.command_source = outer; + ok } fn syntax_failure(&mut self, e: &CommandError, command: &str) { self.command_failure(e.message.clone()); @@ -1097,6 +1112,12 @@ impl Game<'_> { Target::Player => { // ServerPlayer.teleport: removeVehicle before placing the player. self.stop_riding(); + // A server's player teleported within their level is placed + // there at once: the arrival they were waiting for elsewhere + // in it (whose chunks lose their tickets) is abandoned. + if let Some(conn) = self.lan_acting { + self.server_trips.retain(|trip| !trip.is(conn)); + } if let Some(p) = self.player.as_mut() { p.position = v; p.velocity = Vec3::new(0.0, 0.0, 0.0); @@ -1676,6 +1697,8 @@ impl Game<'_> { value: 0, age: 0, on_ground: false, + count: 1, + ticks: 0, }); name = Text::translate("entity.minecraft.experience_orb", &[]); } @@ -1864,11 +1887,12 @@ impl Game<'_> { } } } - let Some((_, new)) = horizon_client::save::decode_block_entity(&tag) else { + let Some((_, mut new)) = horizon_client::save::decode_block_entity(&tag) else { return false; }; + let rolled = self.take_loot_table(key, &tag, &mut new); let after = horizon_client::save::encode_block_entity(key, &new, &name); - if after == before { + if after == before && !rolled { return false; } self.world.block_entities.insert(key, new); @@ -1876,6 +1900,79 @@ impl Game<'_> { let _ = self.world.set(pos[0], pos[1], pos[2], state); true } + /// RandomizableContainer.tryLoadLootTable for a tag a command gives a + /// container (`setblock`, `data`): an empty container with a `LootTable` + /// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and + /// the roll is kept with its chunk, so that the container saves, and + /// `data get` shows, its table and seed until its items change. A tag + /// without one drops what the container had. True when it had a table. + pub(super) fn take_loot_table( + &mut self, + key: (i32, i32, i32), + tag: &horizon_world::nbt::Tag, + entity: &mut horizon_client::containers::BlockEntity, + ) -> bool { + use horizon_client::{containers::BlockEntity, save, structure_blocks}; + let table = save::text(tag.get("LootTable")); + let rolled = match (table, entity) { + (Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => { + let loot = structure_blocks::loot_data(&self.options.resources); + let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2)); + let biome = self.biome_name_at(at).unwrap_or_default(); + Some(structure_blocks::roll_container( + slots, + loot.as_deref(), + table, + structure_blocks::loot_seed(tag), + key, + &biome, + )) + } + _ => None, + }; + let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else { + return false; + }; + let held = |c: &horizon_client::chunk::Chunk| { + c.saved + .as_ref() + .is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key)) + }; + if rolled.is_none() && !held(chunk) { + return false; + } + let chunk = std::sync::Arc::make_mut(chunk); + let mut extras = chunk + .saved + .take() + .map(std::sync::Arc::unwrap_or_clone) + .unwrap_or_default(); + extras.rolled_loot.retain(|r| r.pos != key); + let taken = rolled.is_some(); + extras.rolled_loot.extend(rolled); + chunk.saved = Some(std::sync::Arc::new(extras)); + taken + } + /// The loot table and seed a container still holding its roll saves + /// (`RolledLoot::restore`), in place of its items. + pub(super) fn restore_loot_table( + &self, + key: (i32, i32, i32), + tag: &mut horizon_world::nbt::Tag, + ) { + let horizon_world::nbt::Tag::Compound(map) = tag else { + return; + }; + let rolled = self + .world + .chunks + .get(&(key.0 >> 4, key.2 >> 4)) + .and_then(|c| c.saved.as_ref()) + .and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key)); + if let Some(r) = rolled { + r.restore(map); + } + } fn cmd_fill(&mut self, ctx: &Context) -> R { let a = self.block_pos_arg(ctx, "from")?; let b = self.block_pos_arg(ctx, "to")?; @@ -2572,38 +2669,7 @@ impl Game<'_> { /// `PoiTypes`: the point of interest type of a block state. fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> { let s = self.pack.assets.registry.state(state)?; - let name = s.name.strip_prefix("minecraft:")?; - Some(match name { - "blast_furnace" => "minecraft:armorer", - "smoker" => "minecraft:butcher", - "cartography_table" => "minecraft:cartographer", - "brewing_stand" => "minecraft:cleric", - "composter" => "minecraft:farmer", - "barrel" => "minecraft:fisherman", - "fletching_table" => "minecraft:fletcher", - "cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => { - "minecraft:leatherworker" - } - "lectern" => "minecraft:librarian", - "stonecutter" => "minecraft:mason", - "loom" => "minecraft:shepherd", - "smithing_table" => "minecraft:toolsmith", - "grindstone" => "minecraft:weaponsmith", - "bell" => "minecraft:meeting", - "beehive" => "minecraft:beehive", - "bee_nest" => "minecraft:bee_nest", - "nether_portal" => "minecraft:nether_portal", - "lodestone" => "minecraft:lodestone", - "test_instance_block" => "minecraft:test_instance", - n if n.ends_with("_bed") => { - if s.properties.get("part").map(String::as_str) != Some("head") { - return None; - } - "minecraft:home" - } - n if n.ends_with("lightning_rod") => "minecraft:lightning_rod", - _ => return None, - }) + horizon_client::save::poi_type(s).map(|(kind, _)| kind) } /// `LocateCommand.locatePoi`: `PoiManager.findClosestWithType` within /// 256 blocks of the source, over the loaded chunks. diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 18035ddd7..ac16c5e7c 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -286,6 +286,7 @@ impl Game<'_> { if let Tag::Compound(c) = &mut tag { c.remove(&"keepPacked".into()); } + self.restore_loot_table(key, &mut tag); Ok(tag) } DataRef::Storage(id) => Ok(self.score.storage.get(id)), @@ -296,7 +297,8 @@ impl Game<'_> { DataRef::Entity(t) => self.apply_entity_nbt(*t, tag), DataRef::Block(p) => { let key = (p[0], p[1], p[2]); - if let Some((_, new)) = save::decode_block_entity(&tag) { + if let Some((_, mut new)) = save::decode_block_entity(&tag) { + self.take_loot_table(key, &tag, &mut new); self.world.block_entities.insert(key, new); let state = self.world.block(p[0], p[1], p[2]); // Redraw what shows the block entity (signs, banners). diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 30f019997..687eb3f88 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -49,26 +49,12 @@ impl Game<'_> { let mut tracker = Tracker::default(); for &t in &targets { let h = holder(self.target_name(t)); - // ServerRecipeBook.addRecipes/removeRecipes count recipe book - // displays (a transmute recipe has one per material count). - let mut n = 0; - // ServerPlayer.awardRecipes / resetRecipes: the displays changed. - let index = &self.res.stations().recipe_index; - n += if give { - let (count, unlocked) = self.level.recipe_book.add_recipes(index, &recipes); - self.recipe_unlocked(&unlocked); - count as i32 - } else { - let removed = self.level.recipe_book.remove_recipes(index, &recipes); - let count = removed.len() as i32; - // A network player's client drops them from its book. - if self.lan_acting.is_some() && !removed.is_empty() { - self.guest_packets.push(C::RecipeBookRemove( - horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed }, - )); - } - count - }; + // ServerPlayer.awardRecipes / resetRecipes on the target's own + // ServerRecipeBook: a guest's is swapped in for the call, and the + // packets go to its client. + let n = self + .for_player(t, |g| g.change_recipes(give, &recipes)) + .unwrap_or(0); tracker.track(&h, n); } let verb = if give { "give" } else { "take" }; @@ -92,6 +78,27 @@ impl Game<'_> { Ok(total) } + /// ServerRecipeBook.addRecipes/removeRecipes for the player in place: how + /// many recipe book displays changed (a transmute recipe has one per + /// material count). + fn change_recipes(&mut self, give: bool, recipes: &[String]) -> i32 { + let index = &self.res.stations().recipe_index; + if give { + let (count, unlocked) = self.level.recipe_book.add_recipes(index, recipes); + self.recipe_unlocked(&unlocked); + return count as i32; + } + let removed = self.level.recipe_book.remove_recipes(index, recipes); + let count = removed.len() as i32; + // A network player's client drops them from its book. + if self.lan_acting.is_some() && !removed.is_empty() { + self.guest_packets.push(C::RecipeBookRemove( + horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed }, + )); + } + count + } + // ---- /damage ---------------------------------------------------------------- fn damage_kind(ty: &str) -> DamageKind { diff --git a/crates/horizon-client/src/game/composter.rs b/crates/horizon-client/src/game/composter.rs index 3f57229ad..4013155ad 100644 --- a/crates/horizon-client/src/game/composter.rs +++ b/crates/horizon-client/src/game/composter.rs @@ -5,6 +5,7 @@ use super::{Game, commands::compute::ComputeContext}; use horizon_client::{ items::ItemStack, + mobs::villager, particles::{ParticleOptions, ParticleType}, }; use horizon_core::{position::BlockPos, random::RandomSource}; @@ -86,6 +87,82 @@ impl Game<'_> { self.composter_add(pos, &layers); (stack.count > 0).then_some(stack) } + /// `WorkAtComposter.compostItems` for the villager at `mob`: a full + /// composter is emptied, then up to 20 wheat or beetroot seeds go in + /// (keeping 10 of each kind) until the composter reaches level 7. Level + /// event 1500 plays once, at the end. + pub(super) fn villager_compost_items(&mut self, mob: usize, pos: [i32; 3]) { + let at = BlockPos::new(pos[0], pos[1], pos[2]); + let Some(mut before) = self.composter_level(pos) else { + return; + }; + if before == 8 { + self.with_level(|b, level, ticks| b.composter_extract(level, ticks, at)); + let _ = self.process_level_events(); + before = 0; + } + let mut remaining = 20; + let mut amounts: Vec<(std::sync::Arc, i32)> = Vec::new(); + let slots = villager::ext(&self.mobs[mob]).map_or(0, |e| e.inventory.len()); + 'slots: for slot in (0..slots).rev() { + if remaining <= 0 { + break; + } + let stack = villager::ext_mut(&mut self.mobs[mob]).inventory[slot].clone(); + if !matches!( + &*stack.item, + "minecraft:wheat_seeds" | "minecraft:beetroot_seeds" + ) { + continue; + } + let known = amounts.iter().position(|(item, _)| *item == stack.item); + let total = known.map_or(0, |k| amounts[k].1) + stack.count as i32; + match known { + Some(k) => amounts[k].1 = total, + None => amounts.push((stack.item.clone(), total)), + } + let uses = (total - 10).min(remaining).min(stack.count as i32); + if uses <= 0 { + continue; + } + remaining -= uses; + for _ in 0..uses { + self.villager_insert_item(mob, slot, &stack, pos); + if self.composter_level(pos) == Some(7) { + break 'slots; + } + } + } + let after = self.composter_level(pos).unwrap_or(before); + self.with_level(|b, level, ticks| { + b.composter_fill_effects(level, ticks, at, after != before) + }); + let _ = self.process_level_events(); + } + /// `ComposterBlock.insertItem` with a villager's stack: below level 7 a + /// compostable item is used up and may add a layer. + fn villager_insert_item(&mut self, mob: usize, slot: usize, stack: &ItemStack, pos: [i32; 3]) { + if self.composter_level(pos).is_none_or(|level| level >= 7) { + return; + } + let Some(layers) = self.compostable(stack) else { + return; + }; + let context = ComputeContext { + block: Some(pos), + target: None, + }; + let layers = self.provider_int(&layers, &context, 0).unwrap_or(0); + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.with_level(|b, level, ticks| b.composter_layer(level, ticks, at, layers)); + self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); + let _ = self.process_level_events(); + let inventory = &mut villager::ext_mut(&mut self.mobs[mob]).inventory; + inventory[slot].count -= 1; + if inventory[slot].count == 0 { + inventory.remove(slot); + } + } /// `ComposterBlock.handleFill` (level event 1500) on the client: the /// fill sound and ten composter particles above the compost. pub(super) fn composter_fill_event(&mut self, pos: BlockPos, success: bool) { diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 7a632e308..3202b23f4 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -11,6 +11,9 @@ use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode}; const HELP: &[&str] = &[ "status game, player, streaming and renderer state", + "simulation authoritative projectiles in the owner's level", + "server-hold on|off hold simulation and player packets at a control-fixture barrier", + "stall MILLISECONDS stall the window thread (0..5000), leaving transport alive", "key KEY down|up|tap|repeat press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)", "release release every key and mouse button", "mouse left|right|middle down|up|click", @@ -238,6 +241,47 @@ impl Game<'_> { } "sculk" => Ok(Some(self.sculk_command(&words)?)), "status" => Ok(Some(self.control_status())), + "stall" => { + let millis: u64 = arg(1)?.parse()?; + if millis > 5000 { + return Err("stall is limited to 5000 milliseconds".into()); + } + std::thread::sleep(Duration::from_millis(millis)); + ok() + } + "simulation" => { + self.require_world()?; + let value = if let Some(server) = &self.integrated { + server + .call(|g| { + g.in_owner_level(|g| { + json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() }) + }) + }) + .ok_or("the integrated server stopped")? + } else { + json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() }) + }; + Ok(Some(value)) + } + "server-hold" => { + self.require_world()?; + let held = match arg(1)? { + "on" => true, + "off" => false, + _ => return Err("server-hold on|off".into()), + }; + if let Some(server) = &self.integrated { + server + .call(move |g| g.agent.hold_server = held) + .ok_or("the integrated server stopped")?; + } else if self.remote.is_some() { + return Err("server-hold requires the world's host".into()); + } else { + self.agent.hold_server = held; + } + ok() + } "pad" => Ok(Some(self.pad_command(&words)?)), "key" => { let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?; @@ -508,10 +552,23 @@ impl Game<'_> { "mobs" => { self.require_world()?; let radius: f64 = words.get(1).map_or(Ok(64.0), |r| r.parse())?; + if let Some(server) = &self.integrated { + let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position); + let value = server + .call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center))) + .ok_or("the integrated server stopped")?; + return Ok(Some(value)); + } Ok(Some(self.control_mobs(radius))) } "hurtlog" => { self.require_world()?; + if let Some(server) = &self.integrated { + let value = server + .call(|g| g.control_hurt_log()) + .ok_or("the integrated server stopped")?; + return Ok(Some(value)); + } Ok(Some(self.control_hurt_log())) } "resume" => { diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index be7923037..f6961ab13 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -50,9 +50,9 @@ pub(super) struct Creatures { /// The wandering trader spawner's clock. pub trader: super::wandering_trader::Spawner, } -struct ProjWorld<'a, 'w> { - w: &'a MobWorld<'w>, - targets: Vec<(Who, Aabb)>, +pub(super) struct ProjWorld<'a, 'w> { + pub w: &'a MobWorld<'w>, + pub targets: Vec<(Who, Aabb)>, } impl ProjectileWorld for ProjWorld<'_, '_> { fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { @@ -193,6 +193,7 @@ impl Game<'_> { velocity: p.velocity, yaw: self.yaw, hero: self.potions.effects.get("hero_of_the_village").is_some(), + effects: self.potions.effects.list.iter().map(|e| e.id).collect(), uuid: self.level.player_uuid.unwrap_or_default(), allies: std::sync::Arc::new(self.player_allies()), remote: false, @@ -312,6 +313,9 @@ impl Game<'_> { /// The `mobs` command: nearby mobs with position, health and running goals. pub(super) fn control_mobs(&self, radius: f64) -> serde_json::Value { let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position); + self.control_mobs_at(radius, center) + } + pub(super) fn control_mobs_at(&self, radius: f64, center: Vec3) -> serde_json::Value { let list: Vec = self .mobs .iter() @@ -370,6 +374,7 @@ impl Game<'_> { // Drawn seated (Entity.isPassenger); set apart because the // json! above is at the macro recursion limit. state["passenger"] = m.extra.passenger.into(); + state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0)); state }) .collect(); @@ -388,6 +393,9 @@ impl Game<'_> { } pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> { let (rain, thunder) = self.world.weather.levels(1.0); + // Level.canHaveWeather: only the overworld's rain and thunder levels + // ever rise, so nothing rains or thunders in the Nether or the End. + let weather = self.dim == Dim::Overworld; let player = self.player_view(); MobWorld { world: &self.world, @@ -404,8 +412,8 @@ impl Game<'_> { daylight: self.dim != Dim::Nether && self.sky_darken() < 4, game_time: self.world.game_time, day_time: self.world.day_time, - raining: rain > 0.2, - thundering: thunder > 0.9, + raining: weather && rain > 0.2, + thundering: weather && thunder > 0.9, nether: self.dim == Dim::Nether, block_tags: &self.res.items().block_tags, biomes: self.res.biomes(), @@ -490,6 +498,8 @@ impl Game<'_> { self.died.insert(m.id); self.mob_died(&m); } + // Their victims have been looted: no charged creeper is waiting any more. + self.combat.charged_exploders.clear(); let uuid = self.level.player_uuid; let mut snapshot: Vec = self.mobs.iter().map(|m| seen(m, uuid)).collect(); let mut events = Vec::new(); @@ -504,6 +514,17 @@ impl Game<'_> { Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players), _ => Default::default(), }; + // The guests' entity ids by connection, for the shulker bullets fired + // at the guest a mob sees. + let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() { + Some(host) if !guests.is_empty() => host + .hub + .guests + .iter() + .map(|g| (g.conn, g.entity_id as u32)) + .collect(), + _ => Vec::new(), + }; let mut guest_events = Vec::new(); let level_random; { @@ -578,6 +599,19 @@ impl Game<'_> { let kept = m.tick(&w, &mut scratch, &mut events); horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes); if let Some(conn) = seen_guest { + // ShulkerBullet.finalTarget is the guest's entity: the + // bullet homes on it while the projectiles tick, where + // `Who::Player` is the host's own player. + if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) { + for e in &mut events[from..] { + if let MobEvent::Projectile(p) = e + && let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind + && *target == Who::Player + { + *target = Who::Mob(id); + } + } + } let mut k = from; while k < events.len() { if super::lan_mobs::acts_on_player(&events[k]) { @@ -748,7 +782,17 @@ impl Game<'_> { .mobs .iter() .find(|o| o.id == id) - .map(|o| drops::loot_entity(o, None)), + .map(|o| drops::loot_entity(o, None)) + // A charged creeper's blast kills after the creeper left the list. + .or_else(|| { + self.combat + .charged_exploders + .contains(&id) + .then(|| LootEntity { + powered: true, + ..LootEntity::new("minecraft:creeper") + }) + }), None => None, }; let direct = match d.kind { @@ -784,12 +828,12 @@ impl Game<'_> { world_seed: self.level.seed, }; let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences); - if skulls - && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) - && let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) - { + if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) { // Creeper.droppedSkulls: one head per charged explosion. - creeper.extra.charged = false; + self.combat.charged_exploders.retain(|c| *c != id); + if let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) { + creeper.extra.charged = false; + } } let raider = horizon_client::mobs::raider::is_raider(m.species()); for d in items { @@ -807,9 +851,51 @@ impl Game<'_> { } } } + /// `LivingEntity.dropFromGiftLootTable`: the gift table from its random + /// sequence with the mob as `this` (a chicken's egg depends on its + /// variant); the loot data of the game's resources decides, and without it + /// the items' table engine (no entity) rolls it. + fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) { + use horizon_client::mobs::drops; + use horizon_world::loot::EntityLootParams; + let loot = horizon_client::structure_blocks::loot_data(&self.options.resources); + let this = self + .mobs + .iter() + .find(|m| m.id == mob) + .map(|m| drops::loot_entity(m, None)); + let stacks = match (loot, this) { + (Some(loot), Some(this)) if loot.has_table(table) => { + let params = EntityLootParams { + this, + ..EntityLootParams::default() + }; + let seed = self.level.seed; + match loot.random_sequence(table) { + Some(id) => { + let random = self.rand_sequences.get(id, seed); + loot.roll_entity(table, random, ¶ms) + .iter() + .filter(|s| !s.is_empty()) + .map(from_loot) + .collect() + } + None => Vec::new(), + } + } + _ => self + .res + .items() + .loot_table(table, false, None, &mut self.world.random), + }; + for d in stacks { + self.spawn_item(d, at, [0.0, 0.2, 0.0], 10); + } + } pub(super) fn handle_mob_events(&mut self, events: Vec) -> Result<()> { for e in events { match e { + MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at), MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at), MobEvent::Sound(name, at, volume, pitch) | MobEvent::LocalSound(name, at, volume, pitch) => { @@ -871,16 +957,24 @@ impl Game<'_> { owner, } => self.add_fangs(at, yaw, delay, owner), MobEvent::Shoot(..) => {} + MobEvent::ExplosionSource { mob, charged } => { + self.combat.explosion_source = Some(mob); + if charged { + self.combat.charged_exploders.push(mob); + } + } MobEvent::Explode(at, radius) => { // The exploding mob causes the damage (Creeper.explodeCreeper). - let source_mob = self - .mobs - .iter() - .filter(|m| { - (m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) < 1.0 - }) - .map(|m| m.id) - .next(); + let source_mob = self.combat.explosion_source.or_else(|| { + self.mobs + .iter() + .filter(|m| { + (m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) + < 1.0 + }) + .map(|m| m.id) + .next() + }); if let Some(id) = source_mob { self.note_attacker("minecraft:explosion", id); } @@ -911,6 +1005,9 @@ impl Game<'_> { volume, pitch, ); + } else { + // Left for the end of the tick. + t.outbox.push(event); } } } @@ -1013,6 +1110,40 @@ impl Game<'_> { self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open)); self.process_level_events()?; } + MobEvent::BreakDoor { pos } => { + // BreakDoorGoal.tick: removeBlock, then level events 1021 + // and 2001 with the state that is there now. + let air = self.world.air; + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3)); + self.process_level_events()?; + self.level_event_at(1021, pos, 0); + let left = self.world.block(pos[0], pos[1], pos[2]); + self.level_event_at(2001, pos, left.0 as i32); + } + MobEvent::DestroyProgress { mob, pos, progress } => { + self.destroy_block_progress(mob as i32, pos, progress); + // ServerLevel.destroyBlockProgress: every player nearby sees it. + if let Some(host) = self.lan.as_ref() { + host.hub.broadcast_near( + [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])], + 64.0, + &horizon_protocol::packets::play::Clientbound::BlockDestruction( + horizon_protocol::packets::play::BlockDestruction { + id: mob as i32, + pos, + progress: if (0..10).contains(&progress) { + progress as u8 + } else { + 255 + }, + }, + ), + None, + ); + } + } + MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data), MobEvent::SetBlockName { pos, name, effect } => { if effect { let old = self.world.block(pos[0], pos[1], pos[2]); @@ -1035,7 +1166,10 @@ impl Game<'_> { } MobEvent::BreakBlock { pos, drop } => { let old = self.world.block(pos[0], pos[1], pos[2]); - self.destroy_particles(pos, old); + // Block.spawnDestroyParticles: fire breaks leave nothing. + if !self.res.facts().get(old).has_class("BaseFireBlock") { + self.destroy_particles(pos, old); + } if drop { let drops = self.res.items().block_drops_in( self.pack.assets.registry, @@ -1057,6 +1191,9 @@ impl Game<'_> { self.ensure_item_particle_sprites(); } let options = horizon_client::particles::ParticleOptions::simple(ty); + if ty == horizon_client::particles::ParticleType::SonicBoom { + self.lan_sonic_boom(&options, at); + } // Count 0: one particle exactly there, at rest. if count == 0 { self.particles.add(&options, at, [0.0; 3]); @@ -1090,7 +1227,7 @@ impl Game<'_> { self.particles.add(&options, at, velocity); } } - MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount), + MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount), MobEvent::AxolotlSupportPlayer => { let previous = self .potions @@ -1152,6 +1289,10 @@ impl Game<'_> { self.mob_vibration_particle(from, to, ticks) } MobEvent::DigParticles { at } => self.warden_dig_particles(at), + MobEvent::BlockBreakEffect { pos, block } => { + let state = self.world.block(block[0], block[1], block[2]); + self.destroy_particles(pos, state); + } MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius), MobEvent::CreakingHurt { home, @@ -1162,12 +1303,14 @@ impl Game<'_> { self.creaking_tear_down(center, spread) } MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob), + MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target), MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other), MobEvent::VillagerBirth { parent, partner, child, } => self.villager_birth(parent, partner, child), + MobEvent::WorkAtComposter { mob, pos } => self.villager_work_at_composter(mob, pos), MobEvent::GolemCheck { mob, needed } => self.villager_golem_check(mob, needed), MobEvent::Firework { at, color, flight } => { self.villager_firework(at, color, flight) @@ -1188,19 +1331,51 @@ impl Game<'_> { m.ai.aggressive |= aggressive; } } - MobEvent::GiftLoot { table, at } => { - let drops = - self.res - .items() - .loot_table(table, false, None, &mut self.world.random); - for d in drops { - self.spawn_item(d, at, [0.0, 0.2, 0.0], 10); + MobEvent::GiftLoot { mob, table, at } => { + // Turtle.ageBoundaryReached: only with mob loot on. + if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops") + { + continue; } + self.drop_gift_loot(mob, table, at); } } } Ok(()) } + /// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle + /// per egg, a day from grown, with the egg as its home (the sound, the + /// crumbs and the block's removal came from the level). + pub(super) fn hatch_turtles( + &mut self, + pos: [i32; 3], + state: horizon_world::registry::BlockStateId, + ) { + let eggs = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("eggs")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + for i in 0..eggs { + let at = [ + f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2, + f64::from(pos[1]), + f64::from(pos[2]) + 0.3, + ]; + if let Some(id) = self.spawn_mob("minecraft:turtle", at, true) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + m.extra.age = -24000; + m.extra.home = Some(pos); + m.yaw = 0.0; + m.body_yaw = 0.0; + m.head_yaw = 0.0; + } + } + } fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) { let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else { return; @@ -1234,6 +1409,13 @@ impl Game<'_> { m.extra.egg_time = life; } } + /// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the + /// `mob_drops` game rule is on. + fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) { + if self.ui.rules.bool("mob_drops") { + self.spawn_orbs(at, amount); + } + } /// Animal.spawnChildFromBreeding fn breed( &mut self, @@ -1256,6 +1438,8 @@ impl Game<'_> { .unwrap_or_else(|| parent.clone()); let mut child = parent.clone(); child.baby = true; + // TurtleBreedGoal.breed triggers it without a child. + let turtle = p.species() == Species::Turtle; if matches!( ( p.species(), @@ -1268,7 +1452,7 @@ impl Game<'_> { ) { child.kind = "minecraft:mule".into(); } - self.trigger_bred(&parent, &partner_view, Some(&child)); + self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child)); } if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) { q.extra.age = 6000; @@ -1280,6 +1464,19 @@ impl Game<'_> { self.lay_sniffer_egg(&p); return; } + // TurtleBreedGoal.breed: the goal gave the mother her egg; only the + // experience is left, and only with mob loot on. + if p.species() == Species::Turtle { + // One `new ExperienceOrb` of 1 to 7 from the mother's random (not + // split up as `ExperienceOrb.award` does). + if self.ui.rules.bool("mob_drops") + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent) + { + let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32; + self.add_orb([p.position.x, p.position.y, p.position.z], xp); + } + return; + } let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0; let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0; // A horse and a donkey have a mule. @@ -1327,7 +1524,7 @@ impl Game<'_> { } self.world.random = r; let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } if p.species() == Species::Cat { @@ -1348,7 +1545,7 @@ impl Game<'_> { }; } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } if p.species() == Species::Wolf { @@ -1370,7 +1567,7 @@ impl Game<'_> { }; } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } // Nautilus.getBreedOffspring: a tame parent's baby is tame too. @@ -1386,7 +1583,7 @@ impl Game<'_> { } } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); } /// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian: /// mining fatigue III for five minutes unless a stronger or longer one @@ -1482,15 +1679,19 @@ impl Game<'_> { if let Some(a) = attacker.or(owner) { self.note_attacker(damage_type, a); } + // DamageSource.scalesWithDifficulty: a mob (or its projectile's + // owner) caused the hit, not a player. + let living_cause = attacker.or(owner).is_some(); let mut outcome = horizon_client::health::BlockOutcome::default(); if let Some(player) = self.player.as_mut() && let Some(id) = self.motion.health.damage.id(damage_type) - && let Ok(o) = player.hurt_blocking( + && let Ok(o) = player.hurt_caused_by( &self.motion.health, self.world.health_rules, id, amount, blocking, + living_cause, ) && { outcome = o; @@ -1508,8 +1709,10 @@ impl Game<'_> { r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]); } } - self.hurt_armor(amount); - self.thorns(from); + // The thorns hurt the entity that caused the hit, if there is one. + if let Some(a) = attacker.or(owner) { + self.thorns(a); + } } if raised { self.after_block(outcome, amount, attacker, projectile); @@ -1960,6 +2163,23 @@ impl Game<'_> { self.creatures.projectiles.push(arrow); } } + /// `TargetBlock.onProjectileHit`: a target block takes its power from + /// where the projectile landed and resets after 20 ticks for an arrow, 8 + /// for any other projectile. A player's shot counts as a target hit. + fn target_block_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) { + let block = BlockPos::new(pos[0], pos[1], pos[2]); + let arrow = p.arrow.is_some(); + let location = [at.x, at.y, at.z]; + let hit = self.with_level(|b, level, ticks| { + b.target_projectile_hit(level, ticks, block, location, face, arrow) + }); + if hit.is_some() { + let _ = self.process_level_events(); + if p.owner == Some(Who::Player) { + self.stats.custom("target_hit", 1); + } + } + } pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> { self.combat.projectile_owner = match p.owner { Some(Who::Mob(id)) => Some(id), @@ -1969,6 +2189,13 @@ impl Game<'_> { Hit::Entity { at, .. } | Hit::Block { at, .. } => at, Hit::None => return Ok(()), }; + // Projectile.onHitBlock: BlockState.onProjectileHit, for every + // projectile (a wind charge out of the world's top has no face). + if let Hit::Block { pos, face, .. } = hit + && face != [0; 3] + { + self.target_block_hit(p, pos, at, face); + } if let Hit::Entity { who: Who::Mob(id), at, @@ -2331,12 +2558,14 @@ impl Game<'_> { // ShulkerBullet.onHitEntity: 4 damage, then levitation for 10 s. (ProjectileKind::ShulkerBullet { .. }, Hit::Entity { who, .. }) => match who { Who::Player => { - if !self.creative { - self.mob_hits_player( + // Only a hit that hurt (`wasHurt`) brings the levitation. + if !self.creative + && self.mob_hits_player( 4.0, [p.previous.x, p.previous.y, p.previous.z], "minecraft:mob_projectile", - ); + ) + { self.give_player_effect("minecraft:levitation", 200, 0); } } @@ -2345,6 +2574,8 @@ impl Game<'_> { && m.damage(4.0, source) { m.extra.levitation = 200; + // Shulker.hurtServer: a shulker hit by a bullet. + horizon_client::mobs::shulker::hit_by_bullet(m); } } }, @@ -2426,21 +2657,22 @@ impl Game<'_> { .as_ref() .map(|h| h.item.to_string()) .unwrap_or_default(); + // Bucketable.bucketMobPickup: a water bucket scoops up a fish, a + // tadpole or an axolotl, with the data its bucket carries. if item == "minecraft:water_bucket" - && self.mobs[index].species() == Species::Axolotl && self.mobs[index].alive() + && let Some((bucket, sound)) = bucketable(self.mobs[index].species()) { - let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1); + let mut filled = ItemStack::new(bucket, 1); filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components( &self.mobs[index], ))); - self.audio.play( - "minecraft:item.bucket.fill_axolotl", - Some(self.mobs[index].center()), - 1.0, - 1.0, - ); + self.audio + .play(sound, Some(self.mobs[index].center()), 1.0, 1.0); self.trigger_item("minecraft:filled_bucket", &filled); + // Leashable.dropLeash, then discard. + let id = self.mobs[index].id; + self.break_leash(id, true, false); self.mobs[index].removed = true; if self.creative { if let Some(rest) = self.give(filled) { @@ -2494,6 +2726,19 @@ impl Game<'_> { } return Ok(true); } + // Creeper.mobInteract: any item in #creeper_igniters, damaged when it + // is damageable and used up otherwise. + let creeper_igniter = self + .res + .items() + .item_tags + .get("minecraft:creeper_igniters") + .is_some_and(|t| t.contains(&*item)); + let damageable = self + .res + .items() + .get(&item) + .is_some_and(|i| i.max_damage > 0); let m = &mut self.mobs[index]; if !m.alive() { return Ok(false); @@ -2509,6 +2754,7 @@ impl Game<'_> { let mut handled = true; let mut drops: Vec<(String, u32)> = Vec::new(); let is_food = m.kind.food.contains(&item); + let baby_turtle = species == Species::Turtle && m.is_baby(); match species { Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => { give = Some("minecraft:milk_bucket"); @@ -2734,10 +2980,21 @@ impl Game<'_> { consume = true; sound = Some("entity.iron_golem.repair"); } - Species::Creeper if item == "minecraft:flint_and_steel" => { + Species::Creeper if creeper_igniter => { m.extra.flag = true; - sound = Some("item.flintandsteel.use"); - self.damage_held_tool(); + // FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE, + // at the creeper's feet with a pitch from its own random. + let event = if item == "minecraft:fire_charge" { + "item.firecharge.use" + } else { + "item.flintandsteel.use" + }; + eat = Some((event, 1.0, m.random.next_float() * 0.4 + 0.8)); + if damageable { + self.damage_held_tool(); + } else { + consume = true; + } } // Parrot.mobInteract: parrot food may tame a wild one (one time in // ten); a parrot never takes it as Animal food (isFood is false). @@ -2787,6 +3044,14 @@ impl Game<'_> { if !handled { return Ok(false); } + // Turtle.ageBoundaryReached: food that finishes a baby's growth. + if baby_turtle && !self.mobs[index].is_baby() { + self.handle_mob_events(vec![MobEvent::GiftLoot { + mob: self.mobs[index].id, + table: "minecraft:gameplay/turtle_grow", + at: feet, + }])?; + } self.swing_arm(); if let Some(s) = sound { let pitch = @@ -3220,6 +3485,26 @@ impl Game<'_> { } } } +/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water +/// bucket picks up. +fn bucketable(species: Species) -> Option<(&'static str, &'static str)> { + let fish = "minecraft:item.bucket.fill_fish"; + Some(match species { + Species::Cod => ("minecraft:cod_bucket", fish), + Species::Salmon => ("minecraft:salmon_bucket", fish), + Species::Pufferfish => ("minecraft:pufferfish_bucket", fish), + Species::TropicalFish => ("minecraft:tropical_fish_bucket", fish), + Species::Tadpole => ( + "minecraft:tadpole_bucket", + "minecraft:item.bucket.fill_tadpole", + ), + Species::Axolotl => ( + "minecraft:axolotl_bucket", + "minecraft:item.bucket.fill_axolotl", + ), + _ => return None, + }) +} /// Mob.getBaseExperienceReward /// The damage type id of a Horizon damage kind. pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str { @@ -3264,6 +3549,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), } } /// A generated loot stack as a Horizon item stack. diff --git a/crates/horizon-client/src/game/crossbow.rs b/crates/horizon-client/src/game/crossbow.rs index 70ccba8d3..bc49c4b90 100644 --- a/crates/horizon-client/src/game/crossbow.rs +++ b/crates/horizon-client/src/game/crossbow.rs @@ -6,6 +6,7 @@ //! finds the ammo for bows and crossbows. use super::{Game, shield::OFFHAND_SLOT, view_vector}; use horizon_client::{ + enchant::EffectContext, items::ItemStack, mobs::Who, projectiles::{Projectile, ProjectileKind}, @@ -152,6 +153,20 @@ impl Game<'_> { }; let charged = std::mem::take(&mut bow.data_mut().charged); bow.tidy(); + let weapon = bow.clone(); + // AbstractArrow's constructor: `getPiercingCount` of the weapon. + let pierce = self + .res + .stations() + .enchantments + .modify_value( + Some(&weapon), + "minecraft:projectile_piercing", + &EffectContext::default(), + &mut self.world.random, + 0.0, + ) + .clamp(0.0, 127.0) as u8; let count = charged.len(); // getShootingPower: a rocket among them slows every shot. let power = if charged.iter().any(|s| &*s.item == FIREWORK_ROCKET) { @@ -204,6 +219,7 @@ impl Game<'_> { if let Some(a) = arrow.arrow.as_mut() { a.set_item(ammo); a.set_owner(Some(Who::Player)); + a.pierce = pierce; } arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0); self.creatures.projectiles.push(arrow); diff --git a/crates/horizon-client/src/game/cushions.rs b/crates/horizon-client/src/game/cushions.rs new file mode 100644 index 000000000..0847321d6 --- /dev/null +++ b/crates/horizon-client/src/game/cushions.rs @@ -0,0 +1,494 @@ +//! Cushions: `CushionItem.useOn` placement on the top face of a block, +//! `Cushion.canBePlacedAt`/`survives`, `Cushion.interact` (sit), +//! `BlockAttachedEntity.hurtServer` (break, dropping the matching colour), +//! `BlockAttachedEntity.tick` and `CushionRenderer`. +//! +//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state, +//! only an entity whose `Pos` is the click point's height. Placement uses +//! outline bounds, collision shapes and suffocation predicates. +use super::{Game, Hit, view_vector}; +use horizon_client::entities::{Cushion, dye_index}; +use horizon_client::items::ItemStack; +use horizon_client::mobs::anim::Poses; +use horizon_core::geometry::{Aabb, Vec3}; +use winit::keyboard::KeyCode; + +/// `Direction.fromYRot(yaw).toYRot()`: the nearest 90 degree step. +pub(crate) fn snap_yaw(yaw: f32) -> f32 { + (((f64::from(yaw) / 90.0 + 0.5).floor() as i64) & 3) as f32 * 90.0 +} + +fn aabb(bounds: ([f64; 3], [f64; 3])) -> Aabb { + Aabb::new( + Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]), + Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]), + ) +} + +fn overlaps(a: Aabb, b: Aabb) -> bool { + a.intersects(b) +} +use horizon_client::cushion::blocks_in; + +struct PlacementWorld<'a, 'r>(&'a Game<'r>); +impl horizon_client::cushion::World for PlacementWorld<'_, '_> { + fn outline_bounds(&self, p: [i32; 3]) -> Option { + let boxes = self.0.outline_boxes(p); + let mut shape = *boxes.first()?; + for b in &boxes[1..] { + for k in 0..3 { + shape[k] = shape[k].min(b[k]); + shape[k + 3] = shape[k + 3].max(b[k + 3]); + } + } + Some(Aabb::new( + Vec3::new(shape[0], shape[1], shape[2]), + Vec3::new(shape[3], shape[4], shape[5]), + )) + } + + fn collision_shapes(&self, bounds: Aabb) -> Vec { + let binding = self.0.res.collision(); + let mut world = horizon_client::collisions::SceneCollisions { + world: &self.0.world, + binding, + subject: None, + }; + // Include neighbour blocks whose collision shape can reach outside their cell. + blocks_in(bounds.inflate(Vec3::new(1.0, 1.0, 1.0))) + .filter_map(|p| { + let state = self.0.world.block(p[0], p[1], p[2]); + binding + .collision_shape(&mut world, state, p) + .ok() + .map(|s| s.moved(Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2])))) + }) + .collect() + } + fn suffocating(&self, p: [i32; 3]) -> bool { + let state = self.0.world.block(p[0], p[1], p[2]); + let facts = self.0.res.facts().get(state); + let name = facts.name.as_ref(); + // Blocks' explicit predicates, then the default causes_suffocation/full-shape rule. + match name { + "minecraft:dirt_path" + | "minecraft:farmland" + | "minecraft:mud" + | "minecraft:soul_sand" => true, + "minecraft:piston" | "minecraft:sticky_piston" => self + .0 + .pack + .assets + .registry + .state(state) + .is_some_and(|s| s.properties.get("extended").is_some_and(|p| p == "false")), + "minecraft:mangrove_roots" | "minecraft:moving_piston" => false, + _ if name.ends_with("_leaves") + || name.ends_with("glass") + || name.ends_with("copper_grate") => + { + false + } + // ShulkerBoxBlock: suffocating while closed; a box seen open + // here is rare enough to count as closed. + _ if name.ends_with("shulker_box") => true, + _ => { + facts.full_collision + && self + .0 + .res + .items() + .block_in_tag("minecraft:causes_suffocation", name) + } + } + } +} + +impl Game<'_> { + /// The cushion under the crosshair, its distance and the hit point. + pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> { + let eye = self.eye_position()?; + let dir = view_vector(self.yaw, self.pitch); + let reach: f64 = if self.creative { 5.0 } else { 3.0 }; + let mut best: Option<(usize, f64, [f64; 3])> = None; + for (i, c) in self.cushions.iter().enumerate() { + let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else { + continue; + }; + if best.is_none_or(|b| d < b.1) { + best = Some((i, d, std::array::from_fn(|k| eye[k] + dir[k] * d))); + } + } + best + } + + pub(super) fn cushion_survives(&self, c: &Cushion) -> bool { + horizon_client::cushion::survives(&PlacementWorld(self), aabb(c.bounds())) + } + + /// `Cushion.destroyIfInFire`: a fire-tagged block inside the deflated box. + fn cushion_in_fire(&self, c: &Cushion) -> bool { + let bounds = aabb(c.bounds()).next_deflated(); + blocks_in(bounds).any(|p| { + let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2])); + self.res.items().block_in_tag("minecraft:fire", &facts.name) + }) + } + + /// `CushionItem.useOn`: on the top face only, spawning at the click's + /// height above the clicked block (or the replaceable block itself). + pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool { + let Some(info) = self.res.items().get(&stack.item).cloned() else { + return false; + }; + let Some(default_color) = info.cushion_color.as_deref() else { + return false; + }; + let color_override = stack + .data() + .and_then(|d| d.other("minecraft:cushion/color")) + .and_then(|tag| match tag { + horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()), + _ => None, + }); + let color = dye_index(color_override.as_deref().unwrap_or(default_color)); + // CushionItem.recalculateContextForSpecialCollisionShapes: open containers + // (cauldrons, hoppers and composters) use the collision ray's actual height. + let mut adjusted = Hit { + pos: hit.pos, + face: hit.face, + point: hit.point, + }; + let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); + if self.res.items().block_in_tag( + "minecraft:cushion_uses_collision_shape", + &self.res.facts().get(state).name, + ) && let Some(eye) = self.eye_position() + { + let start = Vec3::new(eye[0], eye[1], eye[2]); + let point = Vec3::new(hit.point[0], hit.point[1], hit.point[2]); + let end = point.add_vector(point.subtract(start).normalize().scale(0.001)); + let binding = self.res.collision(); + let mut world = horizon_client::collisions::SceneCollisions { + world: &self.world, + binding, + subject: None, + }; + if let Ok(shape) = binding.collision_shape(&mut world, state, hit.pos) + && let Some(result) = shape.clip( + start, + end, + horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]), + ) + { + adjusted.face = result.direction.normal(); + adjusted.point = [result.location.x, result.location.y, result.location.z]; + } + } + let hit = &adjusted; + if hit.face != [0, 1, 0] { + return false; + } + // BlockPlaceContext: a replaceable clicked block is replaced. + let clicked = self + .res + .facts() + .get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2])); + let pos = if clicked.replaceable { + hit.pos + } else { + std::array::from_fn(|i| hit.pos[i] + hit.face[i]) + }; + // `Vec3.atCenterOfWithY(clickedPos, clickLocation.y)`. + let y = hit.point[1]; + let position = Vec3::new(f64::from(pos[0]) + 0.5, y, f64::from(pos[2]) + 0.5); + let bounds = aabb(( + [position.x - 0.5, position.y, position.z - 0.5], + [position.x + 0.5, position.y + 0.25, position.z + 0.5], + )); + if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) { + return false; + } + // `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`. + if self + .cushions + .iter() + .any(|c| overlaps(aabb(c.bounds()), bounds)) + { + return false; + } + self.next_entity += 1; + let id = self.next_entity; + // `BlockAttachedEntity.setPos`: `BlockPos.containing(position)`. + let attached = [ + position.x.floor() as i32, + position.y.floor() as i32, + position.z.floor() as i32, + ]; + self.cushions.push(Cushion { + id, + position, + pos: attached, + yaw: snap_yaw(self.yaw), + color, + check_interval: 0, + }); + self.world + .modified + .insert((attached[0] >> 4, attached[2] >> 4)); + // `destroyIfInFire`: placed into fire, it breaks at once. + let placed = self.cushions.len() - 1; + if self.cushion_in_fire(&self.cushions[placed]) { + self.break_cushion(placed, false); + } + self.audio.play( + "minecraft:entity.cushion.place", + Some([position.x, position.y, position.z]), + 0.75, + 0.8, + ); + let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None); + self.post_game_event( + "minecraft:entity_place", + [position.x, position.y, position.z], + ctx, + ); + if !self.creative { + self.consume_held(); + } + true + } + + /// `Cushion.interact`: sit down unless sneaking or already occupied. + pub(super) fn interact_cushion(&mut self) -> bool { + let Some((i, d, _)) = self.cushion_hit() else { + return false; + }; + if !self.before_block(d) { + return false; + } + // isSecondaryUseActive. + if self.keys.contains(&KeyCode::ShiftLeft) { + return false; + } + let id = self.cushions[i].id; + let occupied = self.riding == Some(id) + || self + .lan + .as_ref() + .is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(id))); + // `Cushion.interact`: an occupied cushion (`isVehicle`) passes. + if occupied { + return false; + } + // `Player.startRiding` gives up the vehicle it already has; the + // client never sends that click, so pass instead. + if self.riding.is_some() { + return false; + } + self.riding = Some(id); + let p = self.cushions[i].position; + self.audio.play( + "minecraft:entity.cushion.sit", + Some([p.x, p.y, p.z]), + 1.0, + 1.0, + ); + self.swing_arm(); + true + } + + /// `Cushion.hurtServer` from a player: killed, then the item dropped. + pub(super) fn attack_cushion(&mut self) -> bool { + let Some((i, d, _)) = self.cushion_hit() else { + return false; + }; + if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) { + return false; + } + // `skipAttackInteraction` when `mayInteract` is false. + if !self.may_build() { + return true; + } + self.break_cushion(i, true); + true + } + + /// `Cushion.dropItem`: the break sound and particles, then the coloured + /// item unless drops are off or the breaker has infinite materials. + pub(super) fn break_cushion(&mut self, index: usize, by_player: bool) { + let c = self.cushions.remove(index); + let p = c.position; + self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4)); + if self.riding == Some(c.id) { + self.riding = None; + } + self.audio.play( + "minecraft:entity.cushion.break", + Some([p.x, p.y, p.z]), + 1.0, + 1.0, + ); + self.cushion_break_particles(&c); + if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) { + return; + } + let stack = ItemStack { + item: std::sync::Arc::from(c.item()), + count: 1, + damage: 0, + extra: None, + }; + self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10); + } + + /// `Cushion.showBreakingParticles`: ten of the matching wool block. + fn cushion_break_particles(&mut self, c: &Cushion) { + let name = format!("minecraft:{}_wool", c.color_name()); + let Some(state) = self.pack.assets.registry.default_state(&name) else { + return; + }; + let at = [ + c.position.x.floor() as i32, + c.position.y.floor() as i32, + c.position.z.floor() as i32, + ]; + let Some((sprite, tint)) = self.particle_sprite(state, at) else { + return; + }; + let options = horizon_client::particles::ParticleOptions::block( + horizon_client::particles::ParticleType::Block, + sprite, + tint, + ); + let p = c.position; + self.particles.send_particles( + &options, + [p.x, p.y + 0.666_666_7, p.z], + [0.25, 0.0625, 0.25], + 0.05, + 10, + ); + } + + /// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100. + pub(super) fn tick_cushions(&mut self) { + let ticking = self.entity_ticking_area(); + let mut i = 0; + while i < self.cushions.len() { + let p = self.cushions[i].position; + if !ticking(p.x.floor() as i32, p.z.floor() as i32) { + i += 1; + continue; + } + let previous = self.cushions[i].check_interval; + self.cushions[i].check_interval += 1; + if previous < 100 { + i += 1; + continue; + } + self.cushions[i].check_interval = 0; + let c = self.cushions[i].clone(); + if self.cushion_in_fire(&c) || !self.cushion_survives(&c) { + self.break_cushion(i, false); + continue; + } + i += 1; + } + } + + /// `Player.rideTick` on a cushion: hold the seat, and get up on sneak. + pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool { + let Some(c) = self + .cushions + .iter() + .find(|c| Some(c.id) == self.riding) + .cloned() + else { + return false; + }; + if keys.shift { + let at = c.dismount(); + self.riding = None; + if let Some(p) = self.player.as_mut() { + self.previous_position = p.position; + p.position = at; + p.velocity = Vec3::ZERO; + p.fall_distance = 0.0; + } + // `Cushion.removePassenger`: the get-up sound for a rider that + // left rather than one whose cushion was killed. + self.audio.play( + "minecraft:entity.cushion.get_up", + Some([c.position.x, c.position.y, c.position.z]), + 1.0, + 1.0, + ); + return true; + } + let seat = c.seat(); + if let Some(p) = self.player.as_mut() { + self.previous_position = p.position; + p.position = seat; + p.velocity = Vec3::ZERO; + p.fall_distance = 0.0; + } + true + } + + /// `CushionRenderer`: the coloured model, rotated to the nearest + /// direction, with its bottom on the cushion's position. + pub(super) fn cushion_geometry( + &self, + camera: glam::DVec3, + out: &mut Vec, + ) { + let Some(model) = self.cushion_model.as_ref() else { + return; + }; + let sprites = &self.gui.atlas.sprites; + for c in &self.cushions { + let entity_start = out.len(); + let a = aabb(c.bounds()); + let (lo, hi) = (a.min_position(), a.max_position()); + let (lo, hi) = ( + glam::DVec3::new(lo.x, lo.y, lo.z), + glam::DVec3::new(hi.x, hi.y, hi.z), + ); + let range = + super::entity_culling::default_range(hi - lo, self.settings.entity_distance); + if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) { + continue; + } + let Some(uv) = sprites + .get(&format!("entity/cushion/{}_cushion", c.color_name())) + .map(|r| [r.u0, r.v0, r.u1, r.v1]) + else { + continue; + }; + let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera; + let base = glam::Mat4::from_translation(p.as_vec3()) + * glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians()) + * glam::Mat4::from_rotation_x(std::f32::consts::PI) + * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0)); + // `EntityRenderer.getPackedLightCoords` probes at the eye height. + let l = self.world.light( + c.position.x.floor() as i32, + (c.position.y + 0.2125).floor() as i32, + c.position.z.floor() as i32, + ); + let packed = (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8); + horizon_client::mobs::render::draw( + model, + base, + &Poses::new(model), + uv, + 0xFFFF_FFFF, + packed, + 0, + out, + ); + self.cull_entity_vertices(out, entity_start); + } + } +} diff --git a/crates/horizon-client/src/game/dispense_fixture.rs b/crates/horizon-client/src/game/dispense_fixture.rs index 267f323c3..33da8b5ce 100644 --- a/crates/horizon-client/src/game/dispense_fixture.rs +++ b/crates/horizon-client/src/game/dispense_fixture.rs @@ -725,6 +725,11 @@ impl Game<'_> { match self.world.block_entities.get_mut(&p) { Some(BlockEntity::Jukebox(j)) => j.record = stack, Some(BlockEntity::Lectern(l)) => l.book = stack, + Some(BlockEntity::Campfire(c)) => { + if let Some(cell) = c.items.get_mut(slot) { + *cell = stack; + } + } Some(e) => { if let Some(cell) = e.slots_mut().get_mut(slot) { *cell = stack; @@ -1126,14 +1131,18 @@ impl Game<'_> { let (p, v) = (b.position, b.velocity); out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], "")); } + // A merged orb prints once per count, as the oracle does: whether orbs + // merge depends on entity ids, which differ from vanilla's. for o in &self.orbs { let (p, v) = (o.position, o.velocity); - out.push(line( - "minecraft:experience_orb", - [p.x, p.y, p.z], - [v.x, v.y, v.z], - "", - )); + for _ in 0..o.count.max(1) { + out.push(line( + "minecraft:experience_orb", + [p.x, p.y, p.z], + [v.x, v.y, v.z], + "", + )); + } } for c in &self.carts { let (p, v) = (c.position, c.velocity); diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index 8c316deeb..fff03ef1c 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -12,11 +12,13 @@ use horizon_client::{ hud_layout, hud_overlays, mesher::Vertex, particles::{ParticleOptions, ParticleType}, + text::Text, }; use horizon_core::{ geometry::{Aabb, Vec3}, random::{LegacyRandom, RandomSource}, }; +use horizon_protocol::entity_data::Value; use horizon_world::nbt::Tag; /// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals @@ -1558,6 +1560,7 @@ impl Game<'_> { y -= 1.0; } self.breath_clouds.push(Breath { + id: 0, at: [x, y.floor() + 1.0, z], radius: 5.0, age: 0, @@ -1684,32 +1687,17 @@ impl Game<'_> { .play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch); } } - // checkCrystals: heal 1 every 10 ticks from the nearest crystal within 32. + // checkCrystals: heal 1 every 10 ticks from the nearest crystal, found + // among those touching the dragon's box grown by 32. let d = self.dragon.as_mut().expect("dragon"); if d.nearest_crystal.is_some() && d.ticks % 10 == 0 && d.health < MAX_HEALTH { - d.health += 1.0; + // LivingEntity.setHealth clamps to the maximum. + d.health = (d.health + 1.0).min(MAX_HEALTH); } if d.random.next_int_bound(10).unwrap_or(1) == 0 { let p = d.position; - d.nearest_crystal = self - .crystals - .iter() - .enumerate() - .filter(|(_, c)| { - (c.position.x - p.x).abs() <= 32.0 - && (c.position.y - p.y).abs() <= 32.0 - && (c.position.z - p.z).abs() <= 32.0 - }) - .min_by(|a, b| { - let da = (a.1.position.x - p.x).powi(2) - + (a.1.position.y - p.y).powi(2) - + (a.1.position.z - p.z).powi(2); - let db = (b.1.position.x - p.x).powi(2) - + (b.1.position.y - p.y).powi(2) - + (b.1.position.z - p.z).powi(2); - da.total_cmp(&db) - }) - .map(|(i, _)| i); + let nearest = self.nearest_crystal_to(p); + self.dragon.as_mut().expect("dragon").nearest_crystal = nearest; } let d = self.dragon.as_mut().expect("dragon"); d.yaw = wrap(d.yaw); @@ -1870,7 +1858,7 @@ impl Game<'_> { pl.velocity.z += zd / dd * 4.0; } if !sitting { - g.hurt_player_by("minecraft:mob_attack", 5.0); + g.dragon_hurts_player("minecraft:mob_attack", 5.0); } }); } @@ -1879,7 +1867,7 @@ impl Game<'_> { for h in heads { if h.inflate(Vec3::new(1.0, 1.0, 1.0)).intersects(p.bounds) { self.as_fight_player(p.who, |g| { - g.hurt_player_by("minecraft:mob_attack", 10.0); + g.dragon_hurts_player("minecraft:mob_attack", 10.0); }); } } @@ -2037,6 +2025,7 @@ impl Game<'_> { // assignDirectionalMovement: the direction times the acceleration power 0.1. let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1); self.dragon_fireballs.push(DragonFireball { + id: 0, position: at, previous: at, velocity, @@ -2083,6 +2072,7 @@ impl Game<'_> { }); if hit_block || hit_player || b.age > 600 { self.breath_clouds.push(Breath { + id: 0, at: [next.x, next.y, next.z], radius: 3.0, age: 0, @@ -2161,10 +2151,20 @@ impl Game<'_> { self.breath_clouds.append(&mut clouds); for (who, amount) in harmed { self.as_fight_player(who, |g| { - g.hurt_player_by("minecraft:indirect_magic", amount); + g.dragon_hurts_player("minecraft:indirect_magic", amount); }); } } + /// Damage from the dragon, which is its causing entity + /// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the + /// death message names it. + fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) { + if let Some(id) = self.dragon.as_ref().map(|d| d.id) { + let name = Text::translate("entity.minecraft.ender_dragon", &[]); + self.note_named_attacker(damage_type, id, name); + } + self.hurt_player_by(damage_type, amount); + } /// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars` /// (or a server's packets) put on this client's overlay. pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) { @@ -2202,15 +2202,58 @@ impl Game<'_> { } } pub(super) struct DragonFireball { - position: Vec3, - previous: Vec3, - velocity: Vec3, + /// Its entity id once a server's players track it (0 until then). + pub(super) id: u32, + pub(super) position: Vec3, + pub(super) previous: Vec3, + pub(super) velocity: Vec3, age: i32, } +impl DragonFireball { + /// One a server sent (`handleAddEntity`). + pub(super) fn remote(id: u32, at: Vec3, velocity: Vec3) -> Self { + Self { + id, + position: at, + previous: at, + velocity, + age: 0, + } + } + /// `AbstractHurtingProjectile.tick` on a server's client: `applyInertia` + /// first (power 0.1, inertia 0.95 or 0.8 in water), then the move; only + /// the server decides what it hits. + pub(super) fn remote_step(&mut self, water: bool) { + self.previous = self.position; + self.age += 1; + let v = self.velocity; + // Vec3.normalize: a vector shorter than 1e-5 stays zero. + let l = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt(); + let n = if l < 1.0e-5 { + Vec3::new(0.0, 0.0, 0.0) + } else { + Vec3::new(v.x / l, v.y / l, v.z / l) + }; + let inertia = f64::from(if water { 0.8f32 } else { 0.95f32 }); + let v = Vec3::new( + (v.x + n.x * 0.1) * inertia, + (v.y + n.y * 0.1) * inertia, + (v.z + n.z * 0.1) * inertia, + ); + self.velocity = v; + self.position = Vec3::new( + self.position.x + v.x, + self.position.y + v.y, + self.position.z + v.z, + ); + } +} pub(super) struct Breath { - at: [f64; 3], - radius: f32, - age: i32, + /// Its entity id once a server's players track it (0 until then). + pub(super) id: u32, + pub(super) at: [f64; 3], + pub(super) radius: f32, + pub(super) age: i32, duration: i32, from_dragon: bool, } @@ -3132,6 +3175,212 @@ impl Game<'_> { let _ = self.activate_exit_portal(); self.fight.dirty = true; } + /// `EnderDragon.checkCrystals`' search: the nearest crystal whose box + /// meets the dragon's box grown by 32. + fn nearest_crystal_to(&self, p: Vec3) -> Option { + // The dragon's 16 by 8 box. + let reach = Aabb::new( + Vec3::new(p.x - 8.0, p.y, p.z - 8.0), + Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0), + ) + .inflate(Vec3::new(32.0, 32.0, 32.0)); + self.crystals + .iter() + .enumerate() + .filter(|(_, c)| { + // An end crystal's 2 by 2 box. + reach.intersects(Aabb::new( + Vec3::new(c.position.x - 1.0, c.position.y, c.position.z - 1.0), + Vec3::new(c.position.x + 1.0, c.position.y + 2.0, c.position.z + 1.0), + )) + }) + .min_by(|a, b| { + let da = (a.1.position.x - p.x).powi(2) + + (a.1.position.y - p.y).powi(2) + + (a.1.position.z - p.z).powi(2); + let db = (b.1.position.x - p.x).powi(2) + + (b.1.position.y - p.y).powi(2) + + (b.1.position.z - p.z).powi(2); + da.total_cmp(&db) + }) + .map(|(i, _)| i) + } +} + +/// The dragon on a server's client (`ClientPacketListener.handleAddEntity` +/// and the client side of `EnderDragon.aiStep`): the server flies it and +/// sends its moves, health and phase; the client flaps its wings, keeps +/// its flight history and places its parts, so it can be drawn and hit. +impl Game<'_> { + /// `handleAddEntity` for `minecraft:ender_dragon`. Its constructor + /// sets the full health and the hovering phase until the server's + /// synched data says otherwise. + pub(super) fn spawn_remote_dragon(&mut self, id: i32, position: [f64; 3], yaw: f32) { + let at = Vec3::new(position[0], position[1], position[2]); + let mut d = Dragon::new(at, yaw, i64::from(id)); + d.id = id as u32; + d.phase = Phase::from_id(10); + self.dragon = Some(d); + self.place_dragon_parts(); + if let Some(d) = self.dragon.as_mut() { + for part in &mut d.parts { + part.1 = part.0; + } + } + } + /// The remote dragon, if `id` is it. + fn remote_dragon_mut(&mut self, id: i32) -> Option<&mut Dragon> { + self.dragon.as_mut().filter(|d| d.id as i32 == id) + } + /// `handleSetEntityData`: LivingEntity.DATA_HEALTH_ID and + /// EnderDragon.DATA_PHASE (`onSyncedDataUpdated` sets the phase). + pub(super) fn remote_dragon_data(&mut self, id: i32, values: &[(u8, i32, Value)]) { + let Some(d) = self.remote_dragon_mut(id) else { + return; + }; + for (index, _, value) in values { + match (index, value) { + (9, Value::Float(health)) => d.health = *health, + (16, Value::Int(phase)) => d.set_phase(Phase::from_id(*phase)), + _ => {} + } + } + } + /// `handleSetEntityMotion`: the velocity its wings flap by. + pub(super) fn remote_dragon_motion(&mut self, id: i32, velocity: [f64; 3]) { + if let Some(d) = self.remote_dragon_mut(id) { + d.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]); + } + } + /// `handleHurtAnimation` and `handleDamageEvent`: the red flash. + pub(super) fn remote_dragon_hurt(&mut self, id: i32) { + if let Some(d) = self.remote_dragon_mut(id) { + d.hurt_time = 10; + } + } + /// `handleRemoveEntities`, or a crystal removed: the healing beam + /// keeps pointing at the same crystal. + pub(super) fn remove_remote_dragon(&mut self, id: i32) { + if self.remote_dragon_mut(id).is_some() { + self.dragon = None; + return; + } + let Some(index) = self.crystals.iter().position(|c| c.id as i32 == id) else { + return; + }; + if let Some(d) = self.dragon.as_mut() { + match d.nearest_crystal { + Some(n) if n == index => d.nearest_crystal = None, + Some(n) if n > index => d.nearest_crystal = Some(n - 1), + _ => {} + } + } + } + /// One client tick of the dragon. `lerp`: the step toward its last + /// sent position and yaw (LivingEntity's interpolation), and the + /// steps left including this one. + pub(super) fn tick_remote_dragon(&mut self, lerp: Option<([f64; 3], Option, u32)>) { + let Some(d) = self.dragon.as_mut() else { + return; + }; + d.previous = d.position; + d.previous_yaw = d.yaw; + d.old_flap = d.flap; + d.ticks += 1; + d.hurt_time = (d.hurt_time - 1).max(0); + for part in &mut d.parts { + part.1 = part.0; + } + if let Some((target, yaw, steps)) = lerp { + let n = f64::from(steps.max(1)); + d.position = Vec3::new( + d.position.x + (target[0] - d.position.x) / n, + d.position.y + (target[1] - d.position.y) / n, + d.position.z + (target[2] - d.position.z) / n, + ); + if let Some(yaw) = yaw { + d.yaw += wrap(yaw - d.yaw) / steps.max(1) as f32; + } + } + if !d.alive() { + // EnderDragon.tickDeath on a client: it rises, and bursts + // from tick 180 to 200; the server removes it. + d.death_time += 1; + let (t, p) = (d.death_time, d.position); + if (180..=200).contains(&t) { + let r = &mut d.random; + let o = [ + f64::from((r.next_float() - 0.5) * 8.0), + f64::from((r.next_float() - 0.5) * 4.0), + f64::from((r.next_float() - 0.5) * 8.0), + ]; + self.particles.add( + &ParticleOptions::simple(ParticleType::ExplosionEmitter), + [p.x + o[0], p.y + 2.0 + o[1], p.z + o[2]], + [0.0; 3], + ); + } + if let Some(d) = self.dragon.as_mut() { + d.position.y += 0.1; + for part in &mut d.parts { + part.0.y += 0.1; + } + } + return; + } + // processFlappingMovement and onFlap: its wing beats, and their sound. + let flap_was = (d.old_flap * std::f32::consts::TAU).cos(); + let v = d.velocity; + let mut flap_speed = 0.2 / ((v.x.hypot(v.z) as f32) * 10.0 + 1.0); + flap_speed *= 2f32.powf(v.y as f32); + d.flap += if d.phase.sitting() { 0.1 } else { flap_speed }; + let flap_now = (d.flap * std::f32::consts::TAU).cos(); + let at = [d.position.x, d.position.y, d.position.z]; + if flap_was <= -0.3 && flap_now >= -0.3 { + let pitch = 0.8 + self.world.random.next_float() * 0.3; + self.audio + .play("minecraft:entity.ender_dragon.flap", Some(at), 5.0, pitch); + } + let d = self.dragon.as_mut().expect("dragon"); + if !d.phase.sitting() { + d.growl -= 1; + if d.growl < 0 { + d.growl = 200 + d.random.next_int_bound(200).unwrap_or(0); + let pitch = 0.8 + self.world.random.next_float() * 0.3; + self.audio + .play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch); + } + } + // checkCrystals' search runs on a client too (the healing beam). + let d = self.dragon.as_mut().expect("dragon"); + if d.random.next_int_bound(10).unwrap_or(1) == 0 { + let p = d.position; + let nearest = self.nearest_crystal_to(p); + self.dragon.as_mut().expect("dragon").nearest_crystal = nearest; + } + let d = self.dragon.as_mut().expect("dragon"); + d.yaw = wrap(d.yaw); + d.record(); + self.place_dragon_parts(); + } + /// The dragon's parts as a server's client attacks them: the server's + /// ids for `EnderDragonPart`s follow the dragon's. + pub(super) fn remote_dragon_part_hit( + &self, + eye: [f64; 3], + dir: [f64; 3], + reach: f64, + ) -> Option<(i32, f64, [f64; 3])> { + let d = self.dragon.as_ref().filter(|d| d.alive() && d.id != 0)?; + (0..8) + .filter_map(|i| { + let b = d.part_box(i); + let p = d.parts[i].0; + super::ray_box(eye, dir, reach, b) + .map(|t| ((d.id + 1 + i as u32) as i32, t, [p.x, p.y, p.z])) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + } } #[cfg(test)] diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index aa9f75e06..776cfbd4a 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -255,23 +255,26 @@ impl Game<'_> { let _ = self.wind_burst(feet, radius, knockback, false, false); } } - /// Thorns on worn armor when a mob at `from` hits the player. - pub(super) fn thorns(&mut self, from: [f64; 3]) { - let attacker = self - .mobs - .iter() - .enumerate() - .filter(|(_, m)| m.alive()) - .min_by(|a, b| { - let d = |m: &horizon_client::mobs::Mob| { - (m.position.x - from[0]).powi(2) - + (m.position.y - from[1]).powi(2) - + (m.position.z - from[2]).powi(2) - }; - d(a.1).total_cmp(&d(b.1)) - }) - .map(|(i, _)| i); - let Some(attacker) = attacker else { return }; + /// Thorns on worn armor when the mob `attacker` hits the player: the + /// `affected: attacker` effect of `Enchantment.doPostAttack` hurts the + /// entity that caused the hit. + pub(super) fn thorns(&mut self, attacker: u32) { + let Some(index) = self.mobs.iter().position(|m| m.id == attacker && m.alive()) else { + return; + }; + for damage in self.thorns_damage() { + let m = &mut self.mobs[index]; + m.hurt(damage, None, &mut self.world.random); + let center = [m.position.x, m.position.y, m.position.z]; + self.audio + .play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0); + } + } + /// The victim's side of thorns: for each worn piece whose chance comes up, + /// the damage to deal the attacker (`DamageEntity` min 1, max 5) and the + /// piece's wear (`ChangeItemDamage` 2). + pub(super) fn thorns_damage(&mut self) -> Vec { + let mut hits = Vec::new(); for slot in 0..4 { let Some(stack) = self.armor[slot].clone() else { continue; @@ -299,15 +302,7 @@ impl Game<'_> { if !ok { continue; } - // DamageEntity(min 1, max 5) and ChangeItemDamage(2). - let damage = 1.0 + self.world.random.next_float() * 4.0; - let center = { - let m = &mut self.mobs[attacker]; - m.hurt(damage, None, &mut self.world.random); - [m.position.x, m.position.y, m.position.z] - }; - self.audio - .play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0); + hits.push(1.0 + self.world.random.next_float() * 4.0); if let Some(s) = self.armor[slot].as_mut() { s.damage += 2; if self @@ -320,6 +315,7 @@ impl Game<'_> { } } } + hits } /// `ExperienceOrb.repairPlayerItems`: returns the experience left over. pub(super) fn mending(&mut self, amount: u32) -> u32 { @@ -388,7 +384,9 @@ impl Game<'_> { } } /// Bow enchantments at release: (extra base damage, infinity keeps the arrow). - pub(super) fn bow_enchantments(&mut self) -> (f64, bool) { + /// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the + /// `ammo_use` conditions as the tool, so Infinity only frees plain arrows. + pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) { let Some(bow) = self.inventory[self.selected].clone() else { return (0.0, false); }; @@ -407,7 +405,7 @@ impl Game<'_> { 0.0, ); let ammo_ctx = EffectContext { - tool: Some("minecraft:arrow"), + tool: Some(ammo), items: Some(self.res.items()), ..Default::default() }; diff --git a/crates/horizon-client/src/game/end.rs b/crates/horizon-client/src/game/end.rs index 904c035c5..32d5113b0 100644 --- a/crates/horizon-client/src/game/end.rs +++ b/crates/horizon-client/src/game/end.rs @@ -129,15 +129,32 @@ impl EndState { survive: false, }); } - /// One tick of a server's client: `step` moves each toward where the - /// server put it. - pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) { + /// `EyeOfEnder.tick` on a server's client: it moves by its last known + /// motion; only the server steers it. + pub(super) fn step_remote_eyes(&mut self) { for e in &mut self.eyes { e.previous = e.position; e.life += 1; - e.position = glam::DVec3::from_array(step(e.id, e.position.to_array())); + e.position += e.velocity; } } + /// A server's move: an eye has no interpolation handler, so + /// `moveOrInterpolateTo` puts it there at once. False when `id` isn't one. + pub(super) fn move_remote_eye(&mut self, id: u32, at: [f64; 3]) -> bool { + let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else { + return false; + }; + e.position = glam::DVec3::from_array(at); + true + } + /// `handleSetEntityMotion` for an eye. False when `id` isn't one. + pub(super) fn remote_eye_motion(&mut self, id: u32, velocity: [f64; 3]) -> bool { + let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else { + return false; + }; + e.velocity = glam::DVec3::from_array(velocity); + true + } pub(super) fn remove_remote_eye(&mut self, id: u32) { self.eyes.retain(|e| e.id != id); } @@ -1099,6 +1116,7 @@ impl Game<'_> { /// EyeOfEnder.tick: fly, then drop (4 in 5) or shatter after 80 ticks. fn tick_eyes(&mut self) { let mut eyes = std::mem::take(&mut self.end.eyes); + let mut shattered = Vec::new(); eyes.retain_mut(|eye| { eye.previous = eye.position; let next = eye.position + eye.velocity; @@ -1122,10 +1140,44 @@ impl Game<'_> { extra: None, }; self.spawn_item(stack, at, [vx, 0.2, vz], 0); + } else { + shattered.push(at.map(|v| v.floor() as i32)); } false }); self.end.eyes = eyes; + for pos in shattered { + self.level_event_at(2003, pos, 0); + } + } + + /// `LevelEventHandler` 2003: a shattered eye's item crumbs and two rings + /// of portal particles drawn in towards it. + pub(super) fn eye_break_particles(&mut self, pos: [i32; 3]) { + let (x, y, z) = ( + f64::from(pos[0]) + 0.5, + f64::from(pos[1]), + f64::from(pos[2]) + 0.5, + ); + let crumbs = self.item_particle(&ItemStack::new("minecraft:ender_eye", 1)); + for _ in 0..8 { + let r = self.particles.random(); + let v = [ + r.next_gaussian() * 0.15, + r.next_double() * 0.2, + r.next_gaussian() * 0.15, + ]; + self.particles.add(&crumbs, [x, y, z], v); + } + let portal = ParticleOptions::simple(ParticleType::Portal); + let mut angle = 0.0f64; + while angle < std::f64::consts::PI * 2.0 { + let (c, s) = (angle.cos(), angle.sin()); + let at = [x + c * 5.0, y - 0.4, z + s * 5.0]; + self.particles.add(&portal, at, [c * -5.0, 0.0, s * -5.0]); + self.particles.add(&portal, at, [c * -7.0, 0.0, s * -7.0]); + angle += std::f64::consts::PI / 20.0; + } } /// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 6ecba3a54..687884519 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -296,12 +296,14 @@ impl Game<'_> { if !creative && !spectator && hurtable - && let Ok(o) = p.hurt_blocking( + && let Ok(o) = p.hurt_caused_by( &self.motion.health, self.world.health_rules, id, amount, blocking, + // The exploding mob is the causing entity. + exploding.is_some(), ) { outcome = o; @@ -314,9 +316,6 @@ impl Game<'_> { ); } } - if outcome.hurt && !creative { - self.hurt_armor(amount); - } if raised { self.after_block(outcome, amount, None, false); } @@ -391,9 +390,11 @@ impl Game<'_> { [feet[0] - w, feet[1], feet[2] - w], [feet[0] + w, feet[1] + h, feet[2] + w], ); + // The exploding mob is the damage source's entity, so victims + // it kills drop its charged-creeper loot (`Creeper.killedEntity`). let source = horizon_client::mobs::Damage { kind: horizon_client::mobs::DamageKind::Explosion, - attacker: None, + attacker: exploding.map(horizon_client::mobs::Who::Mob), from: Some(center), }; if hurtable && !m.damage(damage(dist, exposure), source) { @@ -620,6 +621,7 @@ impl Game<'_> { &mut self.world.random, Some((&mut self.rand_sequences, self.level.seed)), decay, + false, ); for mut stack in drops { if &*stack.item == "minecraft:air" || stack.count == 0 { diff --git a/crates/horizon-client/src/game/extras.rs b/crates/horizon-client/src/game/extras.rs index 8ed585116..4d21f5288 100644 --- a/crates/horizon-client/src/game/extras.rs +++ b/crates/horizon-client/src/game/extras.rs @@ -1,5 +1,5 @@ -//! Horizon's extras in the game (`docs/extras.md`): the minimap's tiles and -//! image, and its HUD; a mod's zoom (`render.set-zoom`) on the camera; clicks +//! Horizon's extras in the game (`docs/extras.md`); a mod's minimap +//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks //! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`). //! Each hook does nothing while its extra is off and no mod runs. use super::{Dim, Game, Screen}; @@ -128,44 +128,29 @@ impl Game<'_> { } Ok(()) } - /// The minimap that shows, the extra's or else a mod's - /// (`render.set-minimap`): its corner, its side in GUI pixels and its - /// view of the player. + /// The minimap a mod shows (`render.set-minimap`): its corner, its side + /// in GUI pixels and its view of the player. fn minimap_shown(&self) -> Option<(Corner, f32, View)> { let p = self.player.as_ref()?; if self.title.is_some() || self.ui.hide_gui { return None; } - let e = &self.settings.extras; - let (corner, size, rotate, blocks_per_pixel, round) = if e.minimap { - let size = f32::from(e.minimap_size); - ( - e.minimap_corner, - size, - e.minimap_rotate, - e.minimap_zoom, - e.minimap_round, - ) - } else { - let m = self.mod_minimap()?; - (m.corner, m.size, m.rotate, m.blocks_per_pixel, m.round) - }; + let m = self.mod_minimap()?; let view = View { x: p.position.x, z: p.position.z, yaw: self.yaw, - rotate, - blocks_per_pixel, - round, + rotate: m.rotate, + blocks_per_pixel: m.blocks_per_pixel, + round: m.round, }; - Some((corner, size, view)) + Some((m.corner, m.size, view)) } /// Refresh the minimap's tiles within the frame budget, recompose its /// image when the view or a tile changed, and queue the upload. fn update_minimap(&mut self) { let Some((_, _, view)) = self.minimap_shown() else { - let wanted = self.settings.extras.minimap || self.mod_minimap().is_some(); - if !wanted || self.player.is_none() { + if self.mod_minimap().is_none() || self.player.is_none() { self.extras.minimap.clear(); self.extras.minimap_dim = None; } diff --git a/crates/horizon-client/src/game/falling_damage.rs b/crates/horizon-client/src/game/falling_damage.rs index 8eec28d1d..759b4f646 100644 --- a/crates/horizon-client/src/game/falling_damage.rs +++ b/crates/horizon-client/src/game/falling_damage.rs @@ -108,16 +108,10 @@ impl Game<'_> { } else { amount }; - let hurt = if let Some(p) = self.player.as_mut() + if let Some(p) = self.player.as_mut() && let Some(id) = self.motion.health.damage.id(kind) { - p.hurt(&self.motion.health, self.world.health_rules, id, amount) - .unwrap_or(false) - } else { - false - }; - if hurt { - self.hurt_armor(amount); + let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount); } } } diff --git a/crates/horizon-client/src/game/fangs.rs b/crates/horizon-client/src/game/fangs.rs index cdf2d17a5..aed692649 100644 --- a/crates/horizon-client/src/game/fangs.rs +++ b/crates/horizon-client/src/game/fangs.rs @@ -81,16 +81,28 @@ impl Game<'_> { }; bite(self); self.each_guest_hit(bite); + // `AbstractIllager.considersEntityAsAlly`: with no teams, the illager + // tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose + // owner is allied (itself or another illager). Teams are not modelled. + let illager = |s: Species| { + matches!( + s, + Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator + ) + }; + let illagers: Vec = self + .mobs + .iter() + .filter(|m| illager(m.species())) + .map(|m| m.id) + .collect(); + // A missing owner hurts everyone (`currentOwner == null`). + let owner_known = illagers.contains(&f.owner); for m in &mut self.mobs { - let ally = matches!( - m.species(), - Species::Evoker - | Species::Illusioner - | Species::Vex - | Species::Pillager - | Species::Vindicator - | Species::Witch - ); + let ally = owner_known + && (illager(m.species()) + || m.species() == Species::Vex + && illagers.contains(&(m.extra.counter2 as u32))); if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) { m.damage( 6.0, diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index 7838c9590..38fd74ed8 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -17,6 +17,8 @@ use horizon_core::{ random::RandomSource, }; +pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17; + pub(super) struct Rocket { /// The entity id a server tracks it by (0 until it is tracked). pub id: u32, @@ -71,6 +73,18 @@ impl Rocket { (self.position, self.velocity) } } +/// `damageAmount * (float)Math.sqrt((5.0 - this.distanceTo(target)) / 5.0)` +/// from the rocket at `from` to a target at `to`; `Entity.distanceTo` sums +/// the squared offsets in floats. +fn blast_damage(damage: f32, from: Vec3, to: Vec3) -> f32 { + let (x, y, z) = ( + (from.x - to.x) as f32, + (from.y - to.y) as f32, + (from.z - to.z) as f32, + ); + let distance = f64::from(x * x + y * y + z * z).sqrt() as f32; + damage * ((5.0 - f64::from(distance)) / 5.0).sqrt() as f32 +} pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec) { // The item's default component: flight 1, no explosions. stack @@ -249,23 +263,34 @@ impl Game<'_> { /// `FireworkRocketEntity.explode` and `dealExplosionDamage`. fn explode_rocket(&mut self, r: &Rocket) { let at = [r.position.x, r.position.y, r.position.z]; + // FireworkRocketEntity.explode broadcasts before discard. Send now: + // tick_lan removes missing tracked entities before flushing effects. + if r.id != 0 + && let Some(host) = &self.lan + { + host.hub + .entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at); + } if !r.explosions.is_empty() { let damage = 5.0 + r.explosions.len() as f32 * 2.0; - // The player it boosts takes the full damage, a guest as well. - if r.attached { - match r.guest { - None => self.hurt_player_by("minecraft:fireworks", damage), - Some(conn) => { - if let Some(mut host) = self.lan.take() { - self.as_guest(&mut host, conn, |g| { - g.hurt_player_by("minecraft:fireworks", damage); - }); - self.lan = Some(host); - } - } - } - } let from = r.position; + // `attachedToEntity` takes the full damage, a guest as well; every + // other player in range and in sight takes the blast. + let boosted = r.guest.filter(|_| r.attached); + if r.attached && boosted.is_none() { + self.rocket_hurt_player(damage); + } else { + self.rocket_blast_player(from, damage); + } + if let Some(conn) = boosted + && let Some(mut host) = self.lan.take() + { + self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(damage)); + self.lan = Some(host); + } + // Every other player in the level (LAN guests, the players of a + // server thread), each swapped in as the player. + self.each_guest_hit_except(boosted, |g| g.rocket_blast_player(from, damage)); let hits: Vec<(u32, f32)> = { let world = self.mob_world_base(&[]); self.mobs @@ -285,7 +310,7 @@ impl Game<'_> { ); world.clear_between(from, to, false) }); - sees.then(|| (m.id, damage * ((5.0 - d2.sqrt()) / 5.0).sqrt() as f32)) + sees.then(|| (m.id, blast_damage(damage, from, m.position))) }) .collect() }; @@ -307,6 +332,53 @@ impl Game<'_> { self.particles .fireworks(at, v, r.explosions.clone(), camera); } + /// FireworkRocketEntity.handleEntityEvent: use the synchronized item data. + pub(super) fn remote_firework_explosion(&mut self, id: i32) { + let camera = self.eye_position(); + if let Some(r) = self.rockets.iter().find(|r| r.id == id as u32) { + let at = [r.position.x, r.position.y, r.position.z]; + let v = [r.velocity.x, r.velocity.y, r.velocity.z]; + self.particles + .fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at)); + } + } + /// `dealExplosionDamage` on a player that is not the rocket's + /// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or + /// its middle (`ClipContext.Block.COLLIDER`, no fluids). + fn rocket_blast_player(&mut self, from: Vec3, damage: f32) { + let Some(p) = self.player.as_ref() else { + return; + }; + let feet = p.position; + let d2 = (feet.x - from.x).powi(2) + (feet.y - from.y).powi(2) + (feet.z - from.z).powi(2); + if d2 > 25.0 { + return; + } + let height = f64::from(p.dimensions.height); + let sees = { + let world = self.mob_world_base(&[]); + (0..2).any(|step| { + let to = Vec3::new(feet.x, feet.y + height * 0.5 * f64::from(step), feet.z); + world.clear_between(from, to, false) + }) + }; + if sees { + self.rocket_hurt_player(blast_damage(damage, from, feet)); + } + } + /// `hurtServer` with `damageSources().fireworks`: armor absorbs it and + /// wears (`hurtArmor`); creative and spectator players are immune. + fn rocket_hurt_player(&mut self, amount: f32) { + if self.creative || self.spectator() { + return; + } + if let Some(p) = self.player.as_mut() + && !p.health.dead_or_dying() + && let Some(id) = self.motion.health.damage.id("minecraft:fireworks") + { + let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount); + } + } /// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera, /// lit at the rocket's light probe. pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec) { diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index d454df406..39f9e5ffe 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -154,37 +154,54 @@ impl Game<'_> { let mut damage = 0; if let State::Hooked(id) = hook.state { // pullEntity: 0.1 of the way to the owner. + let d = Vec3::new( + owner.x - hook.position.x, + owner.y - hook.position.y, + owner.z - hook.position.z, + ); + let pulled = |v: Vec3| Vec3::new(v.x + d.x * 0.1, v.y + d.y * 0.1, v.z + d.z * 0.1); + let mut hooked_item = false; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { - let d = Vec3::new( - owner.x - hook.position.x, - owner.y - hook.position.y, - owner.z - hook.position.z, - ); - m.velocity = Vec3::new( - m.velocity.x + d.x * 0.1, - m.velocity.y + d.y * 0.1, - m.velocity.z + d.z * 0.1, - ); + m.velocity = pulled(m.velocity); + } else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) { + item.velocity = pulled(item.velocity); + hooked_item = true; } - damage = 5; + // `hookedIn instanceof ItemEntity ? 3 : 5`. + damage = if hooked_item { 3 } else { 5 }; } else if hook.nibble > 0 { let biome = self.biome_name_at(hook.position); let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources) else { return damage; }; + // `withLuck(this.luck + owner.getLuck())`: the rod's Luck of the Sea + // plus the owner's luck attribute (the Luck and Bad Luck effects). + let owner_luck = self + .player + .as_mut() + .and_then(|p| p.attribute("minecraft:luck").ok()) + .unwrap_or(0.0) as f32; let mut random = self.world.random.clone(); let items = loot.roll( "minecraft:gameplay/fishing", &mut random, - hook.luck as f32, + hook.luck as f32 + owner_luck, biome.as_deref(), Some(hook.open_water), ); self.world.random = random; - // FishingHook.retrieve: fish_caught for a catch. - self.stats.custom("fish_caught", 1); for stack in items { + // FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes. + if self + .res + .items() + .item_tags + .get("minecraft:fishes") + .is_some_and(|t| t.contains(stack.item.as_str())) + { + self.stats.custom("fish_caught", 1); + } let mut item = ItemStack { item: Arc::from(stack.item.as_str()), count: stack.count.max(1) as u32, @@ -231,7 +248,7 @@ impl Game<'_> { } damage } - fn biome_name_at(&self, p: Vec3) -> Option { + pub(super) fn biome_name_at(&self, p: Vec3) -> Option { let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); let c = self.world.chunks.get(&(x >> 4, z >> 4))?; Some( @@ -457,35 +474,47 @@ impl Game<'_> { self.hook = Some(h); return; } - // checkCollision: hook the first living mob it touches. + // checkCollision: hook the first living mob it touches, or (via + // `canHitEntity`) a dropped item. let bb = bounds(h.position); let (lo, hi) = (bb.min_position(), bb.max_position()); - if let Some(m) = self.mobs.iter().find(|m| { - let mb = m.bounds(); - let (a, z) = (mb.min_position(), mb.max_position()); - m.alive() - && a.x < hi.x - && lo.x < z.x - && a.y < hi.y - && lo.y < z.y - && a.z < hi.z - && lo.z < z.z - }) { - h.state = State::Hooked(m.id); + let touches = |b: Aabb| { + let (a, z) = (b.min_position(), b.max_position()); + a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z + }; + let hit = self + .mobs + .iter() + .find(|m| m.alive() && touches(m.bounds())) + .map(|m| m.id) + .or_else(|| { + self.dropped + .iter() + .find(|i| touches(i.bounds())) + .map(|i| i.id) + }); + if let Some(id) = hit { + h.state = State::Hooked(id); h.velocity = Vec3::ZERO; self.hook = Some(h); return; } } State::Hooked(id) => { - match self.mobs.iter().find(|m| m.id == id && m.alive()) { - Some(m) => { - h.position = Vec3::new( - m.position.x, - m.position.y + f64::from(m.height()) * 0.8, - m.position.z, - ) - } + // updateHookedEntity: ride the hooked mob or item at 0.8 of its height. + let hooked = self + .mobs + .iter() + .find(|m| m.id == id && m.alive()) + .map(|m| (m.position, f64::from(m.height()))) + .or_else(|| { + self.dropped + .iter() + .find(|i| i.id == id) + .map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH)) + }); + match hooked { + Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z), None => h.state = State::Flying, } self.hook = Some(h); diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 27d3fb61e..95e2e1e6e 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -64,14 +64,14 @@ impl Game<'_> { }); self.view.tick(eye, target); } - /// The player's `deathTime` while dead or dying. - fn death_time(&self) -> Option { + /// The player's `deathTime` while dead or dying: whole ticks counted by + /// `LivingEntity.tickDeath`, so `deathTime + partialTicks` in + /// `Camera.extractRenderState` rises smoothly across frames. + pub(super) fn death_time(&self) -> Option { let p = self.player.as_ref()?; - p.health.dead_or_dying().then(|| { - self.ui - .died_at - .map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32) - }) + p.health + .dead_or_dying() + .then(|| self.ui.death_time.min(i32::MAX as u32) as i32) } /// The fluid at the camera, as `Camera.getFluidInCamera` reports it. pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid { diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index 7f5e6c26a..d08c5f283 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -355,12 +355,23 @@ impl Game<'_> { * glam::DMat4::from_rotation_y(y_rot.to_radians()); let light = self.world.light(f.pos[0], f.pos[1], f.pos[2]); let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8); + // ItemFrameRenderer.getBlockLightLevel: a glow frame's model is at least 5 bright. + let frame_light = if f.glow { + (light & 0xF0) | (light & 15).max(5) + } else { + light + }; + let frame_packed = + (u32::from(frame_light >> 4) * 16) | ((u32::from(frame_light & 15) * 16) << 8); let mut quad = |m: &glam::DMat4, corners: [[f64; 3]; 4], uv: [[f32; 2]; 4], light: u32| { - for (c, uv) in corners.iter().zip(uv) { - let p = m.transform_point3(glam::DVec3::from_array(*c)); + // The frame model is drawn with the entity shader, lit by its face normal. + let at = corners.map(|c| m.transform_point3(glam::DVec3::from_array(c))); + let n = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero(); + let normal = [0, 1, 2].map(|k| (n[k].clamp(-1.0, 1.0) * 127.0) as i8); + for (p, uv) in at.into_iter().zip(uv) { out.push(Vertex { - normal: [0; 4], + normal: [normal[0], normal[1], normal[2], -127], pos: p.as_vec3().to_array(), uv, layer: 0, @@ -393,7 +404,7 @@ impl Game<'_> { ] }), map_uv(r, *uv), - packed, + frame_packed, ); } } diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index a7218ff3c..d91d3b979 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -151,6 +151,7 @@ impl Game<'_> { before(self.painting_hit().map(|h| h.1)) || before(self.frame_hit().map(|h| h.1)) || before(self.armor_stand_hit().map(|h| h.1)) + || before(self.cushion_hit().map(|h| h.1)) || before(self.boat_hit().map(|h| h.1)) || before(self.cart_hit().map(|h| h.1)) || before(self.dragon_hit().map(|h| h.1)) diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index dcc07f1d1..dd1c04e23 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -372,6 +372,12 @@ impl Game<'_> { let mut next = Instant::now(); while !flags.stop.load(Ordering::Relaxed) { self.run_tasks(); + if self.agent.hold_server { + // Keep servicing control requests so the fixture can release the barrier. + // Unlike /tick freeze, no player's queued packets are applied here. + self.wait_for_next_server_tick(&mut next, true); + continue; + } let paused = flags.paused.load(Ordering::Relaxed); if paused { // Only the connections run. diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index 1d79f077d..b6b7e2fb7 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -196,13 +196,31 @@ impl Game<'_> { skins: &self.skin_downloads, } } - /// The client level as item models read it. + /// The client level as item models read it: worked out once a frame (a + /// screen of items read the same level, its sky sampled once). pub(super) fn item_level(&self) -> LevelState { let dimension = match self.dim { Dim::Overworld => "minecraft:overworld", Dim::Nether => "minecraft:the_nether", Dim::End => "minecraft:the_end", }; + let key = ( + self.frames, + self.world.game_time, + self.world.day_time, + dimension, + ); + let mut cached = self.item_level_cache.borrow_mut(); + if let Some((at, level)) = cached.as_ref() + && *at == key + { + return level.clone(); + } + let level = self.level_state_now(dimension); + *cached = Some((key, level.clone())); + level + } + fn level_state_now(&self, dimension: &'static str) -> LevelState { let sky = self .dim_environment() .sample(self.world.day_time, None, None); diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 1fd40af11..5292cd31b 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -14,7 +14,7 @@ use horizon_client::{ hub::{HostEvent, HostState, Hub, HubConfig, TickCtx}, playerdata, }, - mobs::{Mob, leash::Holder}, + mobs::{Mob, Who, leash::Holder}, text::Text, }; use horizon_core::geometry::Vec3; @@ -71,6 +71,8 @@ pub(super) struct LanHost { /// Guests' commands waiting for the hub to be back in `self.lan`, where /// a command can reach every player (game/lan_actions.rs). pub commands: Vec, + /// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs). + pub quiet: std::collections::VecDeque, /// The entities the guests track, described again every tick /// (`lan_entities`) over last tick's states. states: lan::entity::States, @@ -145,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option { fn mob_meta( m: &Mob, hub: &Hub, + player_entity: Option, meta: &mut Vec<(u8, i32, Value)>, defaults: &mut Vec<(u8, i32, Value)>, ) { @@ -194,6 +197,32 @@ fn mob_meta( let e = &m.extra; let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}")); match kind { + "minecraft:guardian" | "minecraft:elder_guardian" => { + // Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET. + if e.flag2 { + meta.push((16, serializer::BOOLEAN, Value::Bool(true))); + } + let target = if e.flag { + m.ai.target.and_then(|who| match who { + Who::Player => player_entity, + Who::Mob(id) => Some(id as i32), + }) + } else { + None + }; + if let Some(target) = target { + meta.push((17, serializer::INT, Value::Int(target))); + } + defaults.push((16, serializer::BOOLEAN, Value::Bool(false))); + defaults.push((17, serializer::INT, Value::Int(0))); + } + // Bat.DATA_ID_FLAGS: resting in bit 0, flying by default. + "minecraft:bat" => { + if e.flag { + meta.push((16, serializer::BYTE, Value::Byte(1))); + } + defaults.push((16, serializer::BYTE, Value::Byte(0))); + } // Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared. "minecraft:sheep" => { let value = (e.color & 15) | if e.sheared { 16 } else { 0 }; @@ -596,6 +625,7 @@ impl Game<'_> { waypoint_dead: Default::default(), border_sent: None, commands: Vec::new(), + quiet: std::collections::VecDeque::new(), states: Default::default(), }); self.audio.capture = Some(Vec::new()); @@ -634,6 +664,7 @@ impl Game<'_> { self.world.light_log = None; self.audio.capture = None; self.lan_events.clear(); + self.lan_critical_hits.clear(); self.streaming.extra.clear(); if announce { self.system_message(Text::translate( @@ -820,12 +851,13 @@ impl Game<'_> { for m in self.mobs.iter().filter(|m| !m.removed) { // The lead's holder: the player the mob sees (a guest, else the // host) or another mob. A fence knot isn't an entity here. + let player_entity = mob_players + .get(&m.id) + .and_then(|conn| hub.guest(*conn)) + .map(|g| g.entity_id) + .or(host_entity); let leash = m.extra.leash.and_then(|h| match h { - Holder::Player => mob_players - .get(&m.id) - .and_then(|conn| hub.guest(*conn)) - .map(|g| g.entity_id) - .or(host_entity), + Holder::Player => player_entity, Holder::Mob(id) => Some(id as i32), Holder::Knot(_) => None, }); @@ -835,7 +867,7 @@ impl Game<'_> { e.on_ground = m.on_ground; e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z]; e.needs_sync = m.needs_sync; - mob_meta(m, hub, &mut e.meta, &mut e.defaults); + mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults); mob_equipment(m, hub, &mut e.equipment); self.mob_attributes(m, &mut e.attributes); e.leash = leash; @@ -912,6 +944,13 @@ impl Game<'_> { for m in &mut self.mobs { m.needs_sync = false; } + // `runBlockEvents`' broadcast precedes the tick's block changes + // (`ChunkHolder.broadcastChanges` in `tick_level` below): the + // guest re-runs a piston's `triggerEvent` while its level still + // has the blocks as they were before the move. + for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) { + host.hub.block_event(pos, b0, b1, state); + } let entities = states.as_slice(); let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); let events = { @@ -1032,6 +1071,14 @@ impl Game<'_> { } host.states = states; } + if !host.quiet.is_empty() { + self.lan = Some(host); + self.run_quiet_commands(); + host = self + .lan + .take() + .expect("the hub is back after the quiet commands"); + } self.lan_flush_effects(&mut host); if !paused { self.tick_guests(&mut host); @@ -1137,17 +1184,29 @@ impl Game<'_> { return; } for e in events { - if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = e { - // The acting guest does not predict the egg's teleport - // particles, bone meal's growth, or anvil result sounds - // (these levelEvent calls name no excluded player). - let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) { - None - } else { - self.lan_acting - }; - self.lan_events - .push((*id, [pos.x, pos.y, pos.z], *data, except)); + match e { + horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => { + // The acting guest does not predict the egg's teleport + // particles, bone meal's growth, or anvil result sounds + // (these levelEvent calls name no excluded player). + let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) { + None + } else { + self.lan_acting + }; + self.lan_events + .push((*id, [pos.x, pos.y, pos.z], *data, except)); + } + // `ServerLevel.runBlockEvents` broadcasts a handled block + // event (`ClientboundBlockEventPacket`): the guest re-runs + // the block's `triggerEvent` — a piston's `moveBlocks` is + // what animates the move there. `state` is the block the + // event named, read before it ran (the packet's `block`). + horizon_world::level::LevelEvent::BlockEntityEvent { pos, state, a, b } => { + self.lan_block_events + .push(([pos.x, pos.y, pos.z], *a, *b, state.0)); + } + _ => {} } } } @@ -1165,8 +1224,30 @@ impl Game<'_> { self.lan_entity_events.push((id, event, at)); } } + /// `ServerPlayer.crit`: this window's emitter, and an animation for the + /// attacker's client and observers once the hub is back in place. + pub(super) fn critical_hit( + &mut self, + attacker: Option, + id: i32, + feet: [f64; 3], + width: f64, + height: f64, + ) { + use horizon_client::particles::{ParticleOptions, ParticleType}; + self.particles.tracking_emitter( + ParticleOptions::simple(ParticleType::Crit), + feet, + width, + height, + ); + if self.audio.capture.is_some() { + self.lan_critical_hits.push((attacker, id)); + } + } /// Send the tick's level events, sounds and mob animations. fn lan_flush_effects(&mut self, host: &mut LanHost) { + let critical_hits = std::mem::take(&mut self.lan_critical_hits); for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) { if kind == 2001 { host.broken @@ -1239,6 +1320,14 @@ impl Game<'_> { seen.insert(m.id, (m.hurt_time, dead, swing)); } host.mob_state.insert(self.dim, seen); + for (attacker, target) in critical_hits { + let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| { + host.hub.guest(conn).map(|g| g.entity_id) + }) else { + continue; + }; + host.hub.critical_hit(attacker, target); + } } /// Keep the chunks around every guest loaded. @@ -1406,7 +1495,11 @@ impl Game<'_> { let tag = if secure { "" } else { "[Not Secure] " }; super::dedicated::log("INFO", &format!("{tag}{line}")); } - HostEvent::Chat { line, .. } => self.system_message(Text::literal(line)), + // Player chat (and `/say`'s or `/me`'s, which are chat messages + // too): the host's Chat option decides, as `ChatListener` does + // for the guests' packets. Command feedback is a system message + // and takes the system-message permission instead. + HostEvent::Chat { line, .. } => self.player_message(Text::literal(line)), HostEvent::Warn(line) if dedicated => super::dedicated::log("WARN", &line), HostEvent::Warn(line) => log::warn!("{line}"), HostEvent::Error(line) if dedicated => super::dedicated::log("ERROR", &line), diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 02269f89f..98162ae46 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -77,6 +77,10 @@ pub(super) struct GuestPlay { last_inventory: u64, /// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs). pub(super) portal: horizon_client::nether_portal::PortalState, + /// `ServerPlayer.wonGame`: the End poem is showing, the player waits for + /// its client's PERFORM_RESPAWN (`respawn_requested`). + pub(super) won_game: bool, + pub(super) respawn_requested: bool, /// `Player.takeXpDelay`: ticks until the next orb can be taken. xp_delay: u32, /// What last killed the guest outright (`/kill`): the death message's key. @@ -94,11 +98,14 @@ pub(super) struct GuestPlay { /// A guest's command waiting for the hub (`LanHost::commands`). pub(super) struct GuestCommand { - conn: u32, - command: String, - permission: u8, + pub(super) conn: u32, + pub(super) command: String, + pub(super) permission: u8, /// An integrated server's: no level teleports (yet). - integrated: bool, + pub(super) integrated: bool, + /// A mod's quiet command (game/lan_quiet.rs): its output suppressed, as + /// `CommandSourceStack.withSuppressedOutput` does. + pub(super) quiet: bool, } /// The game's own state while a guest's is swapped in (`enter_guest`), and @@ -872,7 +879,11 @@ impl Game<'_> { .filter(|g| host.hub.here(g)) .filter(|g| { g.menu.as_ref().is_some_and(|m| { - m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos) + (m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)) + || m.pos.is_some_and(|k| { + k.1 == super::minecarts::CART_KEY_Y + && !self.chest_vehicle_still_valid(k.2, g) + }) }) }) .map(|g| g.conn) @@ -886,6 +897,35 @@ impl Game<'_> { } } + /// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest + /// boat is still there and within the guest's entity interaction range + /// plus 4 of its box. + fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool { + let bounds = self + .carts + .iter() + .find(|c| c.id as i32 == id) + .map(|c| c.bounds()) + .or_else(|| { + self.boats + .iter() + .find(|b| b.id as i32 == id) + .map(|b| b.bounds()) + }); + let Some(b) = bounds else { + return false; + }; + let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]]; + let (lo, hi) = (b.min_position(), b.max_position()); + let d = [ + eye[0] - eye[0].clamp(lo.x, hi.x), + eye[1] - eye[1].clamp(lo.y, hi.y), + eye[2] - eye[2].clamp(lo.z, hi.z), + ]; + let range = if g.creative() { 5.0 } else { 3.0 } + 4.0; + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range + } + /// A packet from a guest that the hub does not handle itself. pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) { let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else { @@ -1192,6 +1232,10 @@ impl Game<'_> { { self.container_changed(key); } + // A vehicle's menu edits the vehicle's own items. + if let Some(Slots::Block(key)) = slots { + self.store_cart_items(key); + } self.drop_guest_items(host, gi, drops); } S::ContainerClose(p) => { @@ -1206,6 +1250,9 @@ impl Game<'_> { self.guest_carried_back(host, gi); } S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command), + S::CustomPayload(p) if p.channel == horizon_protocol::quiet::CHANNEL => { + self.guest_quiet_commands(host, conn, &p.data) + } S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p), S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => { if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) { @@ -1304,6 +1351,13 @@ impl Game<'_> { self.guest_suggestions(host, conn, &payload); } S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p), + // handleClientCommand: PERFORM_RESPAWN after the poem leaves the End + // (the next server tick sends the player on). + S::ClientCommand(c) + if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) => + { + host.play.entry(conn).or_default().respawn_requested = true; + } S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn), // ServerStatsCounter.sendStats. S::ClientCommand(c) if c.action == 1 => { @@ -1559,6 +1613,7 @@ impl Game<'_> { command: command.trim_start_matches('/').to_owned(), permission, integrated, + quiet: false, }); } @@ -1573,41 +1628,54 @@ impl Game<'_> { return; } let c = host.commands.remove(0); - let mut host = self.lan.take().expect("checked above"); - let Some(swap) = self.enter_guest(&mut host, c.conn) else { - self.lan = Some(host); - continue; - }; - self.lan = Some(host); - self.chat_capture = Some(Vec::new()); - let level = std::mem::replace(&mut self.ui.registries.permission, c.permission); - self.run_command(&c.command); - self.ui.registries.permission = level; - let lines = self.chat_capture.take().unwrap_or_default(); - let mut host = self.lan.take().expect("a command puts the hub back"); - self.leave_guest(&mut host, swap); - host.hub.sync_all_effects(); - if !c.integrated { - // A teleport into another level happens before its feedback is sent. - self.run_level_teleports(&mut host); - } - // What the command earned is announced before its feedback. - self.announce_guest_advancements(&mut host); - for line in lines { - host.hub.send_to( - c.conn, - &C::SystemChat(play::SystemChat { - content: horizon_client::lan::data::component_nbt(&line.component()), - overlay: false, - }), - ); - } - // The other operators hear of it; never the player whose command it was. - self.send_admin_feedback(&mut host, Some(c.conn)); - self.lan = Some(host); + self.run_guest_command(c); } } + /// One guest's command as that guest, its feedback sent to them; false + /// when it failed (or the guest is gone). + pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool { + let Some(mut host) = self.lan.take() else { + return false; + }; + let Some(swap) = self.enter_guest(&mut host, c.conn) else { + self.lan = Some(host); + return false; + }; + self.lan = Some(host); + self.chat_capture = Some(Vec::new()); + let level = std::mem::replace(&mut self.ui.registries.permission, c.permission); + let ok = if c.quiet { + self.run_command_quietly(&c.command) + } else { + self.run_command(&c.command) + }; + self.ui.registries.permission = level; + let lines = self.chat_capture.take().unwrap_or_default(); + let mut host = self.lan.take().expect("a command puts the hub back"); + self.leave_guest(&mut host, swap); + host.hub.sync_all_effects(); + if !c.integrated { + // A teleport into another level happens before its feedback is sent. + self.run_level_teleports(&mut host); + } + // What the command earned is announced before its feedback. + self.announce_guest_advancements(&mut host); + for line in lines { + host.hub.send_to( + c.conn, + &C::SystemChat(play::SystemChat { + content: horizon_client::lan::data::component_nbt(&line.component()), + overlay: false, + }), + ); + } + // The other operators hear of it; never the player whose command it was. + self.send_admin_feedback(&mut host, Some(c.conn)); + self.lan = Some(host); + ok + } + /// The `broadcastToAdmins` lines a command queued (`source`: the guest /// whose it was; none for the host's or the console's): every other /// operator gets them, and the console logs them as the server's own @@ -1835,6 +1903,10 @@ impl Game<'_> { continue; } if self.on_server_trip(conn) { + // LivingEntity.hurt kills at once (die), whether the level + // has loaded around the player or not: its respawn request + // is taken as soon as it comes. + self.guest_die(host, gi, conn); continue; } let play = host.play.entry(conn).or_default(); @@ -1920,69 +1992,72 @@ impl Game<'_> { self.guest_dig_feedback(host, conn); self.guest_pickups(host, gi); self.guest_orbs(host, gi); - if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead { - self.guest_forget_digging(host, conn); - // FishingHook.shouldStopFishing: its owner is dead. - if let Some(play) = host.play.get_mut(&conn) { - play.hook = None; - } + self.guest_die(host, gi, conn); + } + } + + /// `ServerPlayer.die` for a guest whose health has run out: the death + /// message, the loot and experience, the death screen. + fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) { + if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead { + self.guest_forget_digging(host, conn); + // FishingHook.shouldStopFishing: its owner is dead. + if let Some(play) = host.play.get_mut(&conn) { + play.hook = None; } - let g = &mut host.hub.guests[gi]; - if g.health <= 0.0 && !g.dead { - g.dead = true; - g.last_death = Some(g.position.map(|v| v.floor() as i32)); - let (id, at) = (g.entity_id, g.position); - let reward = Experience { - level: g.xp_level, - progress: g.xp_progress, - total: g.xp_total, - } - .death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR); - let (name, uuid) = (g.name.clone(), g.uuid); - // ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3. - let drops = (g.game_mode != SPECTATOR) - .then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]); - let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take()); - // ServerPlayer.die: with showDeathMessages, the message goes to - // the dying player's death screen and to everyone the team lets - // see it; without it, the death screen gets an empty one. - let message = if self.ui.rules.bool("show_death_messages") { - let me = self.player_text(&name, uuid); - let text = match cause { - Some(key) => horizon_client::text::Text::translate(&key, &[me]), - None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => { - horizon_client::text::Text::translate("death.attack.outOfWorld", &[me]) - } - None => self.guest_death_message(conn, me), - }; - let message = horizon_client::lan::data::component_nbt(&text.component()); - let team = self.score.board.players_team(&name); - if horizon_client::scoreboard::own_death_message_shown(team) { - host.hub.system_message(message.clone()); - if self.options.dedicated.is_some() { - super::dedicated::log( - "INFO", - &format!("System chat: {}", text.plain()), - ); - } else if self.player.is_some() { - self.system_message(text); - } + } + let g = &mut host.hub.guests[gi]; + if g.health <= 0.0 && !g.dead { + g.dead = true; + g.last_death = Some(g.position.map(|v| v.floor() as i32)); + let (id, at) = (g.entity_id, g.position); + let reward = Experience { + level: g.xp_level, + progress: g.xp_progress, + total: g.xp_total, + } + .death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR); + let (name, uuid) = (g.name.clone(), g.uuid); + // ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3. + let drops = + (g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]); + let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take()); + // ServerPlayer.die: with showDeathMessages, the message goes to + // the dying player's death screen and to everyone the team lets + // see it; without it, the death screen gets an empty one. + let message = if self.ui.rules.bool("show_death_messages") { + let me = self.player_text(&name, uuid); + let text = match cause { + Some(key) => horizon_client::text::Text::translate(&key, &[me]), + None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => { + horizon_client::text::Text::translate("death.attack.outOfWorld", &[me]) } - message - } else { - literal("") + None => self.guest_death_message(conn, me), }; - host.hub.send_to( - conn, - &C::PlayerCombatKill(play::PlayerCombatKill { id, message }), - ); - // LivingEntity.die: the death animation, the dying player included. - host.hub.entity_event(id, 3, at); - if let Some(eyes) = drops { - self.as_guest(host, conn, |game| game.drop_inventory(eyes)); + let message = horizon_client::lan::data::component_nbt(&text.component()); + let team = self.score.board.players_team(&name); + if horizon_client::scoreboard::own_death_message_shown(team) { + host.hub.system_message(message.clone()); + if self.options.dedicated.is_some() { + super::dedicated::log("INFO", &format!("System chat: {}", text.plain())); + } else if self.player.is_some() { + self.system_message(text); + } } - self.spawn_orbs(at, reward); + message + } else { + literal("") + }; + host.hub.send_to( + conn, + &C::PlayerCombatKill(play::PlayerCombatKill { id, message }), + ); + // LivingEntity.die: the death animation, the dying player included. + host.hub.entity_event(id, 3, at); + if let Some(eyes) = drops { + self.as_guest(host, conn, |game| game.drop_inventory(eyes)); } + self.spawn_orbs(at, reward); } } @@ -2180,6 +2255,9 @@ impl Game<'_> { let _ = w.tick_environment_damage(); let _ = w.apply_fire_contact(); let _ = w.server_hazards_tick(in_wall); + // Player.tick ends with updatePlayerPose: a swimmer, or a player + // crawling under a block, takes the short box its moves are checked with. + let _ = w.update_player_pose(); let mut events = std::mem::take(&mut w.player.events); // LayeredCauldronBlock.handleEntityOnFireInside along those moves. for event in &events { @@ -2223,6 +2301,7 @@ impl Game<'_> { let marked = std::mem::take(&mut player.health.sync_velocity); let velocity = marked.then_some(player.velocity); game.record_combat(&events); + game.wear_armor(&events); (events, velocity) }); let Some((events, marked)) = events else { @@ -2316,9 +2395,13 @@ impl Game<'_> { } if (next / 10) % 2 == 0 && !creative - && let Some(elytra) = game.armor[1].as_mut() + && let Some(stack) = game.armor[1].clone() { - elytra.damage += 1; + // `ItemStack.hurtAndBreak`: Unbreaking can save the wear. + let loss = game.durability_loss(&stack, 1); + if let Some(elytra) = game.armor[1].as_mut() { + elytra.damage += loss; + } } let ctx = game.player_context(None); game.post_game_event("minecraft:elytra_glide", at, ctx); @@ -2581,7 +2664,13 @@ impl Game<'_> { .random .next_int_bound(touching.len() as i32) .unwrap_or(0) as usize; - let orb = self.orbs.remove(touching[pick]); + // A merged orb gives its value once per count and goes at zero. + let orb = self.orbs[touching[pick]].clone(); + if orb.count <= 1 { + self.orbs.remove(touching[pick]); + } else { + self.orbs[touching[pick]].count -= 1; + } play.xp_delay = 2; host.hub.take_item(orb.id as i32, id, 1, at); // ClientPacketListener.handleTakeItemEntity's sound, at the orb. diff --git a/crates/horizon-client/src/game/lan_chat.rs b/crates/horizon-client/src/game/lan_chat.rs index f678480ab..621955967 100644 --- a/crates/horizon-client/src/game/lan_chat.rs +++ b/crates/horizon-client/src/game/lan_chat.rs @@ -28,7 +28,11 @@ impl Game<'_> { /// SignableCommand.of: the command's message arguments, by name, with /// their text, not past a redirect to the root (`execute ... run`). - fn signable_arguments(&mut self, permission: u8, command: &str) -> Vec<(String, String)> { + pub(super) fn signable_arguments( + &mut self, + permission: u8, + command: &str, + ) -> Vec<(String, String)> { let parse = self.parse_as(permission, command); let nodes = &vanilla::game_dispatcher().nodes; let root = parse.context.root; diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index f74935522..db6235c4d 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -291,6 +291,11 @@ impl Game<'_> { let at = host.hub.guests[gi].position; self.award_furnace_recipes(pos, at); } + // Slot.setChanged on a changed container slot: BlockEntity.setChanged, + // so the chunk is saved and comparators read the block again. + if changed.iter().any(|&s| s < items.len()) { + self.container_changed(pos); + } host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu)); host.hub.inventory_clicked(conn, p, &self.world, &changed); true diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index cd4ba2fb8..529666590 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -245,6 +245,16 @@ impl Game<'_> { } out.push(e); } + for c in &self.cushions { + let p = c.position; + let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false); + // Cushion.DATA_COLOR, set unless it is the default white. + if c.color != 0 { + e.meta + .push((8, serializer::DYE_COLOR, Value::Id(i32::from(c.color)))); + } + out.push(e); + } self.lan_projectile_states(host_entity, hub, out); for o in &self.others { let p = o.position; @@ -279,6 +289,10 @@ impl Game<'_> { self.bolts.iter_mut().for_each(|b| give(&mut b.id)); self.potions.thrown_ids().for_each(&mut give); self.end.eye_ids().for_each(&mut give); + self.breath_clouds.iter_mut().for_each(|b| give(&mut b.id)); + self.dragon_fireballs + .iter_mut() + .for_each(|b| give(&mut b.id)); } /// Primed TNT, falling blocks, projectiles, tridents, firework rockets and @@ -435,6 +449,35 @@ impl Game<'_> { } out.push(e); } + // The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS, + // DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the + // client draws them from these (`tick_remote_clouds`). + let breath = self.network_particle( + &horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::DragonBreath, + ), + None, + ); + for b in self.dragon_fireballs.iter().filter(|b| b.id != 0) { + let p = b.position; + let mut e = state(b.id, "minecraft:dragon_fireball", at(p), 0.0, false); + e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z]; + out.push(e); + } + for b in self.breath_clouds.iter().filter(|b| b.id != 0) { + let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false); + e.meta.push((8, serializer::FLOAT, Value::Float(b.radius))); + if b.age < 20 { + e.meta.push((9, serializer::BOOLEAN, Value::Bool(true))); + } + if let Some(p) = &breath { + e.meta + .push((10, serializer::PARTICLE, Value::Particle(p.clone()))); + } + e.defaults + .push((9, serializer::BOOLEAN, Value::Bool(false))); + out.push(e); + } out.extend(self.lan_dragon_state()); } } diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index dc4d1eb42..6527f1015 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -119,7 +119,17 @@ impl Game<'_> { /// Runs `f` as each guest in play here, for what strikes every player in /// an area (`Level.getEntities`), not only the window's own. - pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) { + pub(super) fn each_guest_hit(&mut self, f: impl FnMut(&mut Self)) { + self.each_guest_hit_except(None, f); + } + + /// [`Self::each_guest_hit`] leaving out the guest `except` (the one a + /// firework rocket is attached to, which it hurts on its own). + pub(super) fn each_guest_hit_except( + &mut self, + except: Option, + mut f: impl FnMut(&mut Self), + ) { let Some(mut host) = self.lan.take() else { return; }; @@ -127,7 +137,7 @@ impl Game<'_> { .hub .guests .iter() - .filter(|g| host.hub.here(g) && g.loaded && !g.dead) + .filter(|g| host.hub.here(g) && g.loaded && !g.dead && Some(g.conn) != except) .map(|g| g.conn) .collect(); for conn in conns { @@ -360,6 +370,7 @@ mod tests { velocity: Vec3::ZERO, yaw: 0.0, hero: false, + effects: Vec::new(), uuid: [0; 4], allies: Default::default(), remote: false, diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 0e35392bf..c41fdf1ad 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -25,6 +25,33 @@ fn rgb(c: [f32; 4]) -> [f32; 3] { } impl Game<'_> { + /// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point. + /// Keep the local event's rendering; connected players receive one at rest. + pub(super) fn lan_sonic_boom(&self, options: &ParticleOptions, at: [f64; 3]) { + let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else { + return; + }; + let packet = C::LevelParticles(play::LevelParticles { + particle, + override_limiter: false, + always_show: false, + pos: at, + dist: [0.0; 3], + max_speed: [0.0; 3], + count: 0, + randomization: 0, + }); + for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) { + // ServerLevel compares the player's block centre, strictly within 32. + let distance: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if distance < 32.0 * 32.0 { + g.send(&packet); + } + } + } + /// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's /// network id and the data it carries (`state` for the block particles). /// None while not serving, or for a particle whose data isn't sent from @@ -68,7 +95,11 @@ impl Game<'_> { /// The options a server's particle stands for (None for a type or data /// this client doesn't draw). - fn received_particle(&mut self, p: &Particle, at: [f64; 3]) -> Option { + pub(super) fn received_particle( + &mut self, + p: &Particle, + at: [f64; 3], + ) -> Option { let name = self .remote .as_ref()? diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index 467c4537c..8e756b7be 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -32,6 +32,8 @@ pub(super) enum Victim { /// The attacker's side of the hit. pub(super) struct Strike { + /// The attacking guest, kept after their turn through `as_guest` ends. + attacker: Option, amount: f32, scale: f32, crit: bool, @@ -119,6 +121,7 @@ impl Game<'_> { } self.swing_arm(); Some(Strike { + attacker: self.lan_acting, amount, scale, crit, @@ -242,14 +245,46 @@ impl Game<'_> { if hurt && strength > 0.0 { g.knock_back_player(f64::from(strength), xd, zd); } - hurt + // The victim's thorns, dealt to the attacker below. + let thorns = if hurt { g.thorns_damage() } else { Vec::new() }; + (hurt, thorns) }; - let hurt = self.ui.rules.bool("pvp") - && harm - && match victim { + let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm { + match victim { Victim::Host => land(self), - Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false), + Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(), + } + } else { + Default::default() + }; + if hurt && s.crit { + let target = match victim { + Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)), + Victim::Guest(conn) => host + .hub + .guest(conn) + .and_then(|g| g.motion.as_deref().map(|p| (g.entity_id, p))), }; + if let Some((id, p)) = target { + let d = p.dimensions; + self.critical_hit( + s.attacker, + id, + [p.position.x, p.position.y, p.position.z], + f64::from(d.width), + f64::from(d.height), + ); + } + } + // `Enchantment.doPostAttack`: thorns hurt the attacking player. + for damage in thorns { + match s.attacker { + None => self.hurt_player_by("minecraft:thorns", damage), + Some(conn) => { + self.guest_hit_by(host, conn, |g| g.hurt_player_by("minecraft:thorns", damage)); + } + } + } let sound = match (hurt, s.crit, s.scale > 0.9) { (false, ..) => "minecraft:entity.player.attack.nodamage", (true, true, _) => "minecraft:entity.player.attack.crit", diff --git a/crates/horizon-client/src/game/lan_quiet.rs b/crates/horizon-client/src/game/lan_quiet.rs new file mode 100644 index 000000000..63d382c02 --- /dev/null +++ b/crates/horizon-client/src/game/lan_quiet.rs @@ -0,0 +1,165 @@ +//! Quiet commands (`horizon_protocol::quiet`): a Horizon client's mod sends a +//! batch of command lines in one payload, and the server runs each exactly as +//! that player typing it would (`guest_command`'s checks, the player's own +//! permission level, at the time it runs) with its output suppressed, as +//! vanilla's functions run (`CommandSourceStack.withSuppressedOutput`): no +//! feedback and no failure in anyone's chat. A batch waits in a queue and runs +//! [`PER_TICK`] lines a player a tick, so a mod can't flood a server; each +//! finished batch is one line in the server's log. +use super::{Game, lan::LanHost, lan_actions::GuestCommand}; +use horizon_protocol::quiet; +use std::collections::{HashMap, VecDeque}; + +/// Lines a player may have waiting at most; a batch past it loses the rest. +pub(super) const PENDING: usize = 4096; +/// Lines run a player a tick at most. +pub(super) const PER_TICK: usize = 64; + +/// A batch waiting to run. +pub(super) struct QuietBatch { + conn: u32, + mod_id: String, + lines: VecDeque, + ran: usize, + failed: usize, + /// Lines past [`PENDING`], never run. + dropped: usize, +} + +impl Game<'_> { + /// A `horizon:quiet_commands` payload from `conn`: queued (or logged as + /// refused when it breaks the payload's caps). + pub(super) fn guest_quiet_commands(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) { + let Some(name) = host.hub.guest(conn).map(|g| g.name.clone()) else { + return; + }; + let batch = match quiet::decode(data) { + Ok(b) => b, + Err(e) => { + super::dedicated::log( + "WARN", + &format!("{name} sent quiet commands that were refused: {e}"), + ); + return; + } + }; + let waiting: usize = host + .quiet + .iter() + .filter(|b| b.conn == conn) + .map(|b| b.lines.len()) + .sum(); + let room = PENDING.saturating_sub(waiting); + let dropped = batch.lines.len().saturating_sub(room); + host.quiet.push_back(QuietBatch { + conn, + mod_id: batch.mod_id, + lines: batch.lines.into_iter().take(room).collect(), + ran: 0, + failed: 0, + dropped, + }); + } + + /// The waiting batches, [`PER_TICK`] lines a player this tick, in order. + pub(super) fn run_quiet_commands(&mut self) { + let mut left: HashMap = HashMap::new(); + let mut i = 0; + loop { + let Some(host) = self.lan.as_mut() else { + return; + }; + let Some(batch) = host.quiet.get_mut(i) else { + return; + }; + let conn = batch.conn; + let budget = left.entry(conn).or_insert(PER_TICK); + let gone = host.hub.guest(conn).is_none(); + if gone || batch.lines.is_empty() { + let batch = host.quiet.remove(i).expect("got it above"); + if !gone { + let name = host + .hub + .guest(conn) + .map_or(String::new(), |g| g.name.clone()); + super::dedicated::log("INFO", &summary(&name, &batch)); + } + continue; + } + if *budget == 0 { + i += 1; + continue; + } + *budget -= 1; + let line = batch.lines.pop_front().expect("not empty"); + let ok = self.run_quiet_line(conn, &line); + if let Some(batch) = self.lan.as_mut().and_then(|h| h.quiet.get_mut(i)) { + batch.ran += 1; + batch.failed += usize::from(!ok); + } + } + } + + /// One line as `guest_command` would run it for `conn`, quietly. False + /// when it is refused or fails. A command that carries a chat message + /// (`say`, `msg`...) is refused: it would need the player's signature. + fn run_quiet_line(&mut self, conn: u32, line: &str) -> bool { + let allowed = self.options.dedicated.is_some() || self.allow_commands(); + let Some(host) = self.lan.as_ref() else { + return false; + }; + let integrated = host.hub.config.integrated; + if !integrated && (!host.settings.guest_commands || !allowed) { + return false; + } + let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else { + return false; + }; + let command = line.trim_start_matches('/'); + if !self.signable_arguments(permission, command).is_empty() { + return false; + } + self.run_guest_command(GuestCommand { + conn, + command: command.to_owned(), + permission, + integrated, + quiet: true, + }) + } +} + +/// The batch's line in the server's log. +fn summary(name: &str, b: &QuietBatch) -> String { + let mut line = format!( + "{name} ran {} quiet command(s) from mod {}: {} failed", + b.ran, b.mod_id, b.failed + ); + if b.dropped > 0 { + line += &format!(", {} dropped (more than {PENDING} waiting)", b.dropped); + } + line +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_log_line_counts_the_batch() { + let b = QuietBatch { + conn: 1, + mod_id: "orbital".into(), + lines: VecDeque::new(), + ran: 12, + failed: 3, + dropped: 0, + }; + assert_eq!( + summary("Alex", &b), + "Alex ran 12 quiet command(s) from mod orbital: 3 failed" + ); + let b = QuietBatch { dropped: 5, ..b }; + assert!(summary("Alex", &b).ends_with(", 5 dropped (more than 4096 waiting)")); + } +} diff --git a/crates/horizon-client/src/game/lan_trading.rs b/crates/horizon-client/src/game/lan_trading.rs index 1bec9809d..7beeeba2f 100644 --- a/crates/horizon-client/src/game/lan_trading.rs +++ b/crates/horizon-client/src/game/lan_trading.rs @@ -144,15 +144,16 @@ impl Game<'_> { .offers .iter() .filter_map(|o| { - let cost = - |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost { - item: hub.item_id(&st.item), - count: st.count as i32, - }; + let cost = |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| { + hub.stack(&Some(st.clone())).map(play::NetCost::from) + }; Some(play::NetOffer { - cost_a: cost(&o.base_cost_a.stack, &mut host.hub), + cost_a: cost(&o.base_cost_a.stack, &mut host.hub)?, result: host.hub.stack(&Some(o.result.clone()))?, - cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)), + cost_b: match &o.cost_b { + Some(b) => Some(cost(&b.stack, &mut host.hub)?), + None => None, + }, out_of_stock: o.out_of_stock(), uses: o.uses, max_uses: o.max_uses, diff --git a/crates/horizon-client/src/game/lan_vehicles.rs b/crates/horizon-client/src/game/lan_vehicles.rs index f16a1e37d..00795aa62 100644 --- a/crates/horizon-client/src/game/lan_vehicles.rs +++ b/crates/horizon-client/src/game/lan_vehicles.rs @@ -56,6 +56,13 @@ impl Game<'_> { let (lo, hi) = corners(a.bounds()); return Some((lo, hi, at(a.position))); } + if let Some(c) = self.cushions.iter().find(|c| c.id == id) { + let (lo, hi) = corners(Aabb::new( + Vec3::new(c.position.x - 0.5, c.position.y, c.position.z - 0.5), + Vec3::new(c.position.x + 0.5, c.position.y + 0.25, c.position.z + 0.5), + )); + return Some((lo, hi, at(c.position))); + } if let Some(f) = self.item_frames.iter().find(|f| f.id == id) { let (lo, hi) = f.bounds(); return Some((lo, hi, centre(f.pos))); @@ -98,9 +105,10 @@ impl Game<'_> { let boat = self.boats.iter().position(|b| b.id == vehicle); let cart = self.carts.iter().any(|c| c.id == vehicle); let mount = self.mobs.iter().any(|m| m.id == vehicle); + let cushion = self.cushions.iter().any(|c| c.id == vehicle); // The vehicle is gone (broken, in another level) or the rider died: // its client drops it with the entity or the death screen. - if dead || (boat.is_none() && !cart && !mount) { + if dead || (boat.is_none() && !cart && !mount && !cushion) { let g = &mut host.hub.guests[gi]; (g.vehicle, g.vehicle_good) = (None, None); return; @@ -115,6 +123,17 @@ impl Game<'_> { // Getting out goes through leave_guest, which tells the client and // teleports it (ServerPlayer.dismountTo). let seat = self.as_guest(host, conn, |game| { + // A cushion does not move: the rider sits on it until it sneaks + // (`Cushion.removePassenger` plays the get-up sound). + if game.cushions.iter().any(|c| Some(c.id) == game.riding) { + if keys.shift { + game.tick_cushion_riding(keys); + return None; + } + let c = game.cushions.iter().find(|c| Some(c.id) == game.riding)?; + let p = c.seat(); + return Some([p.x, p.y, p.z]); + } if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) { if keys.shift { game.dismount(i); @@ -168,6 +187,12 @@ impl Game<'_> { .iter() .find(|c| c.id == vehicle) .map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0)))) + }) + .or_else(|| { + self.cushions + .iter() + .find(|c| c.id == vehicle) + .map(|c| (c.position, c.seat())) }); if let Some((p, seat)) = at { let g = &mut host.hub.guests[gi]; diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 261347c00..29bf6d511 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -3,8 +3,8 @@ //! The game's tick code reads one level: the one in place, held in `Game`'s //! own fields (the world, its streaming and save, block ticks, entities). //! A server (the dedicated server and single player's server thread) keeps -//! the other dimensions' levels here and ticks each one that has players by -//! swapping it into place (`enter_level`), the way `as_guest` swaps a player +//! the other dimensions' levels here and ticks each one with players or forced +//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player //! in, so the tick code runs unchanged, one level at a time. The in-frame //! game holds one level, which follows its player (`enter_dimension`). //! @@ -50,6 +50,7 @@ pub(super) struct Parked { paintings: Vec, item_frames: Vec, armor_stands: Vec, + cushions: Vec, others: Vec, boats: Vec, carts: Vec, @@ -74,6 +75,7 @@ pub(super) struct Parked { lan_events: Vec<(i32, [i32; 3], i32, Option)>, lan_block_events: Vec<([i32; 3], i32, i32, u32)>, lan_entity_events: Vec<(i32, i8, [f64; 3])>, + lan_critical_hits: Vec<(Option, i32)>, /// Only the per-level parts of these are used (`swap_level`). anims: BlockEntityAnims, raiding: Raiding, @@ -148,6 +150,7 @@ impl Game<'_> { paintings: Vec::new(), item_frames: Vec::new(), armor_stands: Vec::new(), + cushions: Vec::new(), others: Vec::new(), boats: Vec::new(), carts: Vec::new(), @@ -172,6 +175,7 @@ impl Game<'_> { lan_events: Vec::new(), lan_block_events: Vec::new(), lan_entity_events: Vec::new(), + lan_critical_hits: Vec::new(), anims: Default::default(), raiding: Default::default(), sculk: Default::default(), @@ -198,6 +202,7 @@ impl Game<'_> { swap(&mut self.paintings, &mut p.paintings); swap(&mut self.item_frames, &mut p.item_frames); swap(&mut self.armor_stands, &mut p.armor_stands); + swap(&mut self.cushions, &mut p.cushions); swap(&mut self.others, &mut p.others); swap(&mut self.boats, &mut p.boats); swap(&mut self.carts, &mut p.carts); @@ -222,6 +227,7 @@ impl Game<'_> { swap(&mut self.lan_events, &mut p.lan_events); swap(&mut self.lan_block_events, &mut p.lan_block_events); swap(&mut self.lan_entity_events, &mut p.lan_entity_events); + swap(&mut self.lan_critical_hits, &mut p.lan_critical_hits); let (a, b) = (&mut self.be_anims, &mut p.anims); swap(&mut a.chests, &mut b.chests); swap(&mut a.books, &mut b.books); @@ -232,6 +238,7 @@ impl Game<'_> { swap(&mut a.pots, &mut b.pots); swap(&mut a.gateways, &mut b.gateways); swap(&mut a.gateway_servers, &mut b.gateway_servers); + swap(&mut a.conduit_servers, &mut b.conduit_servers); self.raiding.swap_level(&mut p.raiding); self.sculk.swap_level(&mut p.sculk); self.creaking.swap_level(&mut p.creaking); @@ -300,8 +307,8 @@ impl Game<'_> { } /// One server tick (`MinecraftServer.tickServer`): each level with - /// players in turn (`tickChildren`), its chunks streamed and loaded and - /// its world ticked. The first pass reads the network and runs the + /// players or forced chunks in turn (`tickChildren`), its chunks streamed + /// and loaded and its world ticked. The first pass reads the network and runs the /// tick's shared parts (the clock, the weather, functions, the autosave); /// the next ones only their level. While `paused`, only the connections /// run. @@ -405,7 +412,7 @@ impl Game<'_> { /// The levels this server tick runs, in `MinecraftServer.levels` order /// (the Overworld, the Nether, the End): every level with a player in - /// it or a `/pregen` task, or the Overworld while nobody is online. + /// it, forced chunks or a `/pregen` task, or the Overworld while idle. pub(super) fn levels_to_tick(&self) -> Vec { let guests = self .lan @@ -414,7 +421,17 @@ impl Game<'_> { let mut dims: Vec = [Dim::Overworld, Dim::Nether, Dim::End] .into_iter() .filter(|d| { - guests.iter().any(|g| g.dimension == d.id()) || self.pregen_level() == Some(*d) + let forced = if *d == self.dim { + !self.streaming.forced.is_empty() + } else { + self.levels + .parked + .get(d) + .is_some_and(|p| !p.streaming.forced.is_empty()) + }; + guests.iter().any(|g| g.dimension == d.id()) + || forced + || self.pregen_level() == Some(*d) }) .collect(); if dims.is_empty() { diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 916bd2a0a..8a3ac2745 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -74,14 +74,74 @@ impl Game<'_> { self.spawn_bolt(at, trap); } } - /// `findLightningTargetAround`: a living entity under open sky in the - /// column (3 blocks around), else the top of the column. + /// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the + /// lightning rod points of interest within 128 blocks of `center` whose + /// block is the top (`WORLD_SURFACE`) of its column; the strike lands one + /// above it. Horizon keeps no point-of-interest index, so this scans the + /// loaded chunks the way `/locate poi` does. + fn find_lightning_rod(&self, center: [i32; 3]) -> Option<[i32; 3]> { + const RANGE: i32 = 128; + let facts = self.res.facts(); + let mut is_rod: std::collections::HashMap = std::collections::HashMap::new(); + let mut best: Option<(i64, [i32; 3])> = None; + let (cx0, cz0) = ((center[0] - RANGE) >> 4, (center[2] - RANGE) >> 4); + let (cx1, cz1) = ((center[0] + RANGE) >> 4, (center[2] + RANGE) >> 4); + for cx in cx0..=cx1 { + for cz in cz0..=cz1 { + let Some(chunk) = self.world.chunks.get(&(cx, cz)) else { + continue; + }; + for (i, section) in chunk.sections.iter().enumerate() { + let mut rod = |state: u32| { + *is_rod.entry(state).or_insert_with(|| { + facts + .get(horizon_world::registry::BlockStateId(state)) + .name + .ends_with("lightning_rod") + }) + }; + if !section.blocks.maybe_has(&mut rod) { + continue; + } + let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32; + for j in 0..4096 { + if !rod(section.get(j)) { + continue; + } + let p = [ + cx * 16 + (j & 15) as i32, + base_y + (j >> 8) as i32, + cz * 16 + ((j >> 4) & 15) as i32, + ]; + let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]] + .map(|d| i64::from(d) * i64::from(d)) + .iter() + .sum::(); + if d > i64::from(RANGE * RANGE) || best.is_some_and(|(b, _)| d >= b) { + continue; + } + let surface = self.world.world_surface(p[0], p[2], |s| facts.is_air(s)); + if p[1] == surface - 1 { + best = Some((d, p)); + } + } + } + } + } + best.map(|(_, p)| [p[0], p[1] + 1, p[2]]) + } + /// `findLightningTargetAround`: the closest lightning rod, else a living + /// entity under open sky in the column (3 blocks around), else the top of + /// the column. fn lightning_target(&mut self, x: i32, z: i32) -> Option<[i32; 3]> { if !self.world.loaded(x, z) { return None; } let top = self.with_level(|b, level, _| b.motion_blocking_top(level, BlockPos::new(x, 0, z))); + if let Some(rod) = self.find_lightning_rod([x, top.y, z]) { + return Some(rod); + } let sees_sky = |g: &Self, p: [i32; 3]| g.world.light(p[0], p[1], p[2]) >> 4 == 15; let mut candidates: Vec<[i32; 3]> = self .mobs @@ -162,11 +222,9 @@ impl Game<'_> { converted: Vec::new(), }); } - /// `spawnFire`: fire at the strike point and `extra` random neighbours. + /// `spawnFire`: fire at the strike point and `extra` random neighbours, + /// wherever `BaseFireBlock.getState` fire can survive. fn bolt_fire(&mut self, at: [f64; 3], extra: i32) { - if self.ui.difficulty < 2 { - return; - } let base = [ at[0].floor() as i32, at[1].floor() as i32, @@ -183,18 +241,13 @@ impl Game<'_> { if !self.res.facts().is_air(state) { continue; } - // BaseFireBlock.getState: soul fire on soul soil and sand. - let below = self.res.facts().get(self.world.block(p[0], p[1] - 1, p[2])); - let name = if matches!(&*below.name, "minecraft:soul_soil" | "minecraft:soul_sand") { - "minecraft:soul_fire" - } else { - "minecraft:fire" - }; - // FireBlock.canSurvive: a sturdy floor (flammable neighbours aside). - if below.sturdy_full >> 1 & 1 == 0 { - continue; - } - if let Some(fire) = self.pack.assets.registry.default_state(name) { + // BaseFireBlock.getState and FireBlock/SoulFireBlock.canSurvive. + let at = BlockPos::new(p[0], p[1], p[2]); + let fire = self.with_level(|b, level, ticks| { + let fire = b.fire_state_for(level, ticks, at); + b.can_survive(level, ticks, at, fire).then_some(fire) + }); + if let Some(fire) = fire { let _ = self.level_set(p, fire); } } @@ -219,7 +272,10 @@ impl Game<'_> { pitch, ); if !b.visual_only { - self.bolt_fire(b.at, 4); + // Only Normal and Hard start fires with the strike itself. + if self.ui.difficulty >= 2 { + self.bolt_fire(b.at, 4); + } self.strike_rod(b.at); } } diff --git a/crates/horizon-client/src/game/mace.rs b/crates/horizon-client/src/game/mace.rs index 8ad5e76c3..34c968980 100644 --- a/crates/horizon-client/src/game/mace.rs +++ b/crates/horizon-client/src/game/mace.rs @@ -24,7 +24,7 @@ const KNOCKBACK_POWER: f32 = 0.7; /// `Entity.isAlliedTo` between the player (`player` its UUID) and a mob /// without teams: `TamableAnimal.considersEntityAsAlly` (a pet it tamed) and /// `Allay.considersEntityAsAlly` (an allay that likes it). -fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool { +pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool { let tamable = matches!( m.species(), Species::Wolf diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 8742962e9..bb936744b 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -159,8 +159,10 @@ impl Containers<'_> { }); } let entity = self.world.block_entities.get(&pos)?; - // A beehive keeps bees, not items: it is no `Container`. - if matches!(entity, BlockEntity::Beehive(_)) { + // A beehive keeps bees, not items, and a campfire cooks its own: neither + // is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops + // in front of them and a hopper leaves them alone. + if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) { return None; } let kind = match (entity, name) { diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 8d4920b48..072059e49 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -87,9 +87,12 @@ pub(super) struct Minecart { pub fuse: i32, /// Hopper minecarts: an unpowered activator rail enables them. pub enabled: bool, + /// Chest minecarts: the loot table rolled into `items` on load, saved + /// again while the items are still that roll. + pub loot: Option, } -const WIDTH: f64 = 0.98; -const HEIGHT: f64 = 0.7; +pub(super) const WIDTH: f64 = 0.98; +pub(super) const HEIGHT: f64 = 0.7; /// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block. fn display_offset(kind: CartKind) -> f64 { if kind == CartKind::Hopper { 1.0 } else { 6.0 } @@ -97,7 +100,7 @@ fn display_offset(kind: CartKind) -> f64 { /// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT. pub(super) const SEAT: f64 = 0.1875 - 0.6; /// The placeholder container key under which an open cart's items live. -pub(super) const CART_KEY_Y: i32 = -100_000; +pub(super) const CART_KEY_Y: i32 = horizon_client::lan::menu::VEHICLE_KEY_Y; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(super) enum Shape { @@ -182,6 +185,7 @@ impl Minecart { push: [0.0; 2], fuse: -1, enabled: true, + loot: None, } } pub fn bounds(&self) -> Aabb { @@ -1117,22 +1121,35 @@ impl Game<'_> { self.swing_arm(); true } - /// Closing a cart's container copies the items back. - pub(super) fn close_cart_container(&mut self) { - let Some(key) = self.container.filter(|k| k.1 == CART_KEY_Y) else { - return; - }; - let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) else { + /// Closing the cart's container open under `key` copies the items back. + pub(super) fn close_cart_container(&mut self, key: (i32, i32, i32)) { + if key.1 != CART_KEY_Y { + return; + } + self.store_cart_items(key); + self.world.block_entities.remove(&key); + if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2) + && c.kind == CartKind::Chest + { + let at = [c.position.x, c.position.y, c.position.z]; + let pitch = self.world.random.next_float() * 0.1 + 0.9; + self.audio + .play("minecraft:block.chest.close", Some(at), 0.5, pitch); + } + } + /// The menu of a chest or hopper minecart or a chest boat works on the + /// vehicle's own items (`ContainerEntity`): the placeholder under `key` + /// that the menu edits is copied back to the vehicle. + pub(super) fn store_cart_items(&mut self, key: (i32, i32, i32)) { + if key.1 != CART_KEY_Y { + return; + } + let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&key) else { return; }; + let items = items.clone(); if let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2) { c.items = items; - let at = [c.position.x, c.position.y, c.position.z]; - if c.kind == CartKind::Chest { - let pitch = self.world.random.next_float() * 0.1 + 0.9; - self.audio - .play("minecraft:block.chest.close", Some(at), 0.5, pitch); - } } else if let Some(b) = self.boats.iter_mut().find(|b| b.id as i32 == key.2) { b.items = items; } @@ -1514,6 +1531,12 @@ impl Minecart { } CartKind::Rideable => {} } + // A loaded loot table goes back while the items are still its roll; + // otherwise the items replace it, as vanilla's unpackLootTable does. + if !self.loot.as_ref().is_some_and(|l| l.restore(map)) { + map.remove(&JavaString::from("LootTable")); + map.remove(&JavaString::from("LootTableSeed")); + } } tag } diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index d6813ca40..424f87d3c 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -76,6 +76,10 @@ pub(super) struct ModsState { /// is still being shown, and a mod answering its own lines would recurse. #[cfg(feature = "mods")] commands: Vec, + /// `command.run-quiet` batches for this window's own world, by mod id, + /// run on the next tick as `commands` are. + #[cfg(feature = "mods")] + quiet: Vec<(String, Vec)>, /// The server's command tree with the running mods' commands added. #[cfg(feature = "mods")] remote_tree: Option, @@ -458,8 +462,110 @@ impl Game<'_> { cps, font_widths: self.mods.font_widths.clone(), players, + view_hit: self.mod_view_hit(), } } + /// The first block along the player's view within + /// [`horizon_mods::RAYCAST_MAX`] and its distance from the eye + /// (`game.raycast`): through the loaded chunks as the crosshair's ray + /// goes (fluids pass), then across the distant terrain's ground heights, + /// a block at a time. + fn mod_view_hit(&self) -> Option<(f32, horizon_mods::Hit)> { + if !self.mods.mods.wants_raycast() { + return None; + } + let eye = self.eye_position()?; + let dir = super::view_vector(self.yaw, self.pitch); + let reach = f64::from(horizon_mods::RAYCAST_MAX); + let (pos, point, state) = match self.clip_ray(eye, dir, reach) { + Some(hit) => ( + hit.pos, + hit.point, + self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]), + ), + None => self.lod_ray(eye, dir, reach)?, + }; + let distance = (0..3).map(|i| (point[i] - eye[i]).powi(2)).sum::(); + let hit = horizon_mods::Hit { + block_x: pos[0], + block_y: pos[1], + block_z: pos[2], + x: point[0], + y: point[1], + z: point[2], + block: self.res.facts().get(state).name.to_string(), + }; + Some((distance.sqrt() as f32, hit)) + } + /// Past the resident chunks: the view walked across the distant + /// terrain's ground heights (`Lod::ground`), a block at a time. Columns in + /// loaded chunks are left to `clip_ray`, which has already missed them. + fn lod_ray( + &self, + eye: [f64; 3], + dir: [f64; 3], + reach: f64, + ) -> Option<([i32; 3], [f64; 3], horizon_world::registry::BlockStateId)> { + let lod = self.lod.as_ref()?; + let mut t = 0.0; + while t <= reach { + let p = [0, 1, 2].map(|i| eye[i] + dir[i] * t); + let (x, z) = (p[0].floor() as i32, p[2].floor() as i32); + if !self.world.loaded(x, z) + && let Some((height, state)) = lod.ground(x, z) + && p[1] <= f64::from(height) + { + return Some(([x, height - 1, z], [p[0], f64::from(height), p[2]], state)); + } + t += 1.0; + } + None + } + /// A mod's `command.run-quiet` lines (vanilla's commands only, as + /// `command.run`): to a server that runs them quietly as one payload + /// (`horizon_protocol::quiet`), to any other as chat commands, or for this + /// window's own world next tick. + fn mod_quiet_commands(&mut self, mod_id: String, lines: Vec) { + let lines: Vec = lines + .into_iter() + .filter(|l| vanilla_command(l.split(' ').next().unwrap_or_default())) + .collect(); + match self.remote.as_ref().map(|r| r.quiet_commands) { + Some(true) => { + for data in horizon_protocol::quiet::encode(&mod_id, &lines) { + self.send(horizon_protocol::packets::play::Serverbound::CustomPayload( + horizon_protocol::packets::configuration::CustomPayload { + channel: horizon_protocol::quiet::CHANNEL.into(), + data, + }, + )); + } + } + // The server checks the player's permissions; its feedback shows. + Some(false) => { + for line in lines { + self.send_chat(&format!("/{line}")); + } + } + None => self.mods.quiet.push((mod_id, lines)), + } + } + /// A quiet batch in this window's own world, as a server runs one + /// (game/lan_quiet.rs): each line as the player typing it, no chat, a + /// chat message refused; one line in the log. + fn run_quiet_batch(&mut self, mod_id: &str, lines: &[String]) { + let permission = self.ui.registries.permission; + let mut failed = 0; + for line in lines { + let ok = self.signable_arguments(permission, line).is_empty() + && self.run_command_quietly(line); + failed += usize::from(!ok); + } + log::info!( + "[mod {mod_id}] ran {} quiet command(s): {failed} failed", + lines.len() + ); + } /// A game tick. pub(super) fn tick_mods(&mut self) { // Here, at the player's level: shown inside a console call (a control @@ -467,6 +573,9 @@ impl Game<'_> { for line in std::mem::take(&mut self.mods.commands) { self.run_command(&line); } + for (mod_id, lines) in std::mem::take(&mut self.mods.quiet) { + self.run_quiet_batch(&mod_id, &lines); + } if !self.mods.mods.is_running() { return; } @@ -561,6 +670,10 @@ impl Game<'_> { } continue; } + Output::QuietCommands { mod_id, lines } => { + self.mod_quiet_commands(mod_id, lines); + continue; + } Output::Stopped { name, reason } => { // Its screen goes with it: the entry's run is gone, so // `mod_ui_view` finds no widgets and the screen closes. diff --git a/crates/horizon-client/src/game/music.rs b/crates/horizon-client/src/game/music.rs index 6f8b5e7ae..fa5e71f7b 100644 --- a/crates/horizon-client/src/game/music.rs +++ b/crates/horizon-client/src/game/music.rs @@ -91,7 +91,7 @@ impl Game<'_> { }) } /// `isEyeInFluid(WATER)`: the eye inside the water of its block column. - fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool { + pub(super) fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool { let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32); let f = self.res.facts().get(self.world.block(bx, by, bz)); if f.fluid != Fluid::Water { diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index f249bd01f..9f7561e53 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -86,6 +86,10 @@ impl Game<'_> { .iter() .filter_map(|m| Some((m, m.custom_name.clone()?, false))); let players = players.into_iter().map(|(m, name)| (m, name, true)); + // Who is riding whom: asked once per tag below, and rescanning every mob + // for each tag made this quadratic in the number of loaded mobs. + let ridden: std::collections::HashSet = + self.mobs.iter().filter_map(|o| o.extra.mount).collect(); for (m, name, player) in named.chain(players) { if !m.alive() { continue; @@ -134,7 +138,7 @@ impl Game<'_> { invisible_to_viewer: invisible, hud_hidden: self.ui.hide_gui, is_camera: false, - is_vehicle: self.mobs.iter().any(|o| o.extra.mount == Some(m.id)), + is_vehicle: ridden.contains(&m.id), }; // MobRenderer.shouldShowName: a visible name, or the mob under the // crosshair; players (LivingEntityRenderer) need neither. @@ -310,7 +314,7 @@ impl Game<'_> { } /// The lines of one name tag, `o` from the camera: the text at /// `text_alpha` in its layer, on the translucent black background - /// (unlit, a hair behind) when `background`. + /// (in `text_layer` like the glyphs, a hair behind) when `background`. #[allow(clippy::too_many_arguments)] fn name_tag_quads( &self, @@ -382,7 +386,7 @@ impl Game<'_> { [white.u0, white.v0, white.u1, white.v1], BACKGROUND, true, - 2, + text_layer, ); } let mut x = x0; diff --git a/crates/horizon-client/src/game/net_harness.rs b/crates/horizon-client/src/game/net_harness.rs index 1ec573e7c..468f1db71 100644 --- a/crates/horizon-client/src/game/net_harness.rs +++ b/crates/horizon-client/src/game/net_harness.rs @@ -3,7 +3,10 @@ //! window, waits until it stands in the world with the chunks around it, //! sends a chat message and `/list`, collects the chat it is shown, leaves //! and writes a JSON report (position, chunks, chat lines, whether the -//! server kept it connected throughout). +//! server kept it connected throughout). With `HORIZON_NET_QUIET` (lines +//! split by `;`) it then sends them as one batch of a mod's quiet commands +//! (`horizon_protocol::quiet`, `tools/quiet_commands_e2e.py`) and waits two +//! seconds for any chat they bring. use super::Game; use horizon_client::Result; use serde_json::json; @@ -23,6 +26,9 @@ impl Game<'_> { let mut step_at = Instant::now(); let mut chat_at = 0; let mut position = None; + let quiet: Vec = std::env::var("HORIZON_NET_QUIET") + .map(|v| v.split(';').map(str::to_owned).collect()) + .unwrap_or_default(); let mut chunks = 0; let mut error = None; while Instant::now() < deadline { @@ -71,6 +77,22 @@ impl Game<'_> { step_at = now; chat_at = shown; } + 2 if answered && !quiet.is_empty() => { + for data in horizon_protocol::quiet::encode("net_harness", &quiet) { + self.send(horizon_protocol::packets::play::Serverbound::CustomPayload( + horizon_protocol::packets::configuration::CustomPayload { + channel: horizon_protocol::quiet::CHANNEL.into(), + data, + }, + )); + } + step = 4; + step_at = now; + } + 4 if now - step_at > Duration::from_secs(2) => { + step = 3; + break; + } 2 if answered => { step = 3; break; diff --git a/crates/horizon-client/src/game/nether_portal.rs b/crates/horizon-client/src/game/nether_portal.rs index 353ad216c..87ba81c93 100644 --- a/crates/horizon-client/src/game/nether_portal.rs +++ b/crates/horizon-client/src/game/nether_portal.rs @@ -454,11 +454,24 @@ impl Game<'_> { for m in &self.mobs { self.portals.before.insert(m.id, m.position); } + for b in &self.boats { + self.portals.before.insert(b.id, b.position); + } + for c in &self.carts { + self.portals.before.insert(c.id, c.position); + } let ids: Vec<(u32, bool)> = self .dropped .iter() .map(|e| (e.id, true)) .chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m)))) + .chain( + self.boats + .iter() + .map(|b| b.id) + .chain(self.carts.iter().map(|c| c.id)) + .map(|id| (id, !self.vehicle_occupied(id))), + ) .collect(); let present: std::collections::HashSet = ids.iter().map(|(id, _)| *id).collect(); self.portals.entities.retain(|id, _| present.contains(id)); @@ -501,8 +514,15 @@ impl Game<'_> { && self.riding != Some(m.id) && m.species() != Species::Wither } - /// `NetherPortalBlock.entityInside` for the items and mobs that moved this - /// tick: the portal blocks their box swept through. + /// Whether the player, a server player or a mob rides vehicle `id`. + fn vehicle_occupied(&self, id: u32) -> bool { + self.riding == Some(id) + || self.ridden_by_guest(id) + || self.mobs.iter().any(|m| m.extra.mount == Some(id)) + } + /// `NetherPortalBlock.entityInside` for the items, mobs and empty boats + /// and minecarts that moved this tick: the portal blocks their box swept + /// through. pub(super) fn find_entity_portals(&mut self) { if self.remote.is_some() { return; @@ -519,8 +539,23 @@ impl Game<'_> { .filter(|m| self.mob_can_use_portal(m)) .map(|m| (m.id, m.position, m.width(), m.height())) .collect(); + let vehicles: Vec<(u32, Vec3, f32, f32)> = self + .boats + .iter() + .map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT)) + .chain(self.carts.iter().map(|c| { + ( + c.id, + c.position, + super::minecarts::WIDTH, + super::minecarts::HEIGHT, + ) + })) + .filter(|(id, ..)| !self.vehicle_occupied(*id)) + .map(|(id, at, w, h)| (id, at, w as f32, h as f32)) + .collect(); let mut portal_sections = std::mem::take(&mut self.portals.sections); - for (id, to, width, height) in items.into_iter().chain(mobs) { + for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) { let from = self.portals.before.get(&id).copied().unwrap_or(to); if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) { inside.push((id, pos)); @@ -678,6 +713,44 @@ impl Game<'_> { } self.portals.arrivals.push(Arrival { plan, tag }); self.portals.dirty = true; + } else if let Some(i) = self.boats.iter().position(|b| b.id == id) { + let b = &self.boats[i]; + let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32); + let plan = self.portal_plan(entry, b.position, width, height, false); + if !self.may_enter(plan.dim) { + return; + } + let uuid = self.uuid_for(id); + let b = &self.boats[i]; + let tag = horizon_client::entity_nbt::boat_to_nbt( + &b.entity_type(), + b.position, + b.velocity, + b.yaw, + uuid, + self.persist_raw(id), + b.chest.then_some(&b.items[..]), + ); + self.boats.remove(i); + self.forget_entity(id); + self.portals.arrivals.push(Arrival { plan, tag }); + self.portals.dirty = true; + } else if let Some(i) = self.carts.iter().position(|c| c.id == id) { + let c = &self.carts[i]; + let (width, height) = ( + super::minecarts::WIDTH as f32, + super::minecarts::HEIGHT as f32, + ); + let plan = self.portal_plan(entry, c.position, width, height, false); + if !self.may_enter(plan.dim) { + return; + } + let uuid = self.uuid_for(id); + let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id)); + self.carts.remove(i); + self.forget_entity(id); + self.portals.arrivals.push(Arrival { plan, tag }); + self.portals.dirty = true; } } /// Entities waiting for the loaded dimension whose exit area is loaded: diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 243eced5c..7ac4ae98b 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -257,6 +257,7 @@ impl Game<'_> { lang::set(lang::Language::load(&[&root], code)); self.refresh_remote_language(); self.ui.chat.rescale(&self.gui.atlas); + self.creative_screen.forget_items(); } /// Settings that Horizon applies beyond render distance, volume and GUI scale. pub(super) fn apply_ui_settings(&mut self) { @@ -268,6 +269,11 @@ impl Game<'_> { r.animated_textures = s.horizon.animated_textures; r.set_mipmap_levels(s.mipmap_levels.max(0) as u32); r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa); + r.set_hdr( + s.horizon.hdr, + s.horizon.hdr_paper_white as f32, + s.horizon.hdr_peak as f32, + ); } // Window.updateFullscreenIfChanged: only a changed option asks the // window to change, so applying settings never undoes a fullscreen diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index ee57e9f2b..569c63083 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -163,7 +163,7 @@ impl Game<'_> { self.pack.context.biomes.clone(), self.pack.context.light.clone(), )?; - // Keep the working GPU resources, but release its exclusive window surface. + // Keep the working GPU resources, and hand its window surface over. if let Some(renderer) = &mut self.renderer { renderer.release_surface(); } @@ -221,6 +221,8 @@ impl Game<'_> { self.apply_ui_settings(); } self.ui.chat.rescale(&self.gui.atlas); + // The creative screen survives the reload; item names may not. + self.creative_screen.forget_items(); Ok(()) } } diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index 45ef8801f..b5e650e70 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -279,7 +279,8 @@ impl Game<'_> { k if super::minecarts::CartKind::from_id(k).is_some() => { let kind = super::minecarts::CartKind::from_id(k).unwrap(); let id = self.next_entity + 1; - if let Some(c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) { + if let Some(mut c) = super::minecarts::Minecart::from_nbt(&tag, id, kind) { + self.roll_cart_loot(&mut c, &tag); self.next_entity = id; self.remember(id, uuid, tag); self.carts.push(c); @@ -314,6 +315,13 @@ impl Game<'_> { self.item_frames.push(f); } } + "minecraft:cushion" => { + self.next_entity += 1; + if let Some(c) = entity_nbt::cushion_from_nbt(&tag, self.next_entity) { + self.remember(c.id, uuid, tag); + self.cushions.push(c); + } + } "minecraft:painting" => { if let Some((pos, facing, variant)) = entity_nbt::painting_from_nbt(&tag) && let Some(v) = self.painting_variants.get(&variant).cloned() @@ -527,6 +535,30 @@ impl Game<'_> { pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> { self.persist.raw.get(&id) } + /// A loaded chest minecart with a loot table and no items: the table + /// rolled now, as generated containers are (Horizon does not unpack on + /// first open), and kept to save it again while unchanged. + fn roll_cart_loot(&self, cart: &mut super::minecarts::Minecart, tag: &Tag) { + let Some(table) = horizon_client::save::text(tag.get("LootTable")) else { + return; + }; + if cart.items.is_empty() || cart.items.iter().any(Option::is_some) { + return; + } + let loot = horizon_client::structure_blocks::loot_data(&self.options.resources); + let p = cart.position; + let pos = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); + let biome = self.biome_name_at(p).unwrap_or_default(); + let seed = horizon_client::structure_blocks::loot_seed(tag); + cart.loot = Some(horizon_client::structure_blocks::roll_container( + &mut cart.items, + loot.as_deref(), + table, + seed, + pos, + &biome, + )); + } fn remember(&mut self, id: u32, uuid: Option<[i32; 4]>, tag: Tag) { self.restore_portal_cooldown(id, &tag); if let Some(u) = uuid { @@ -776,6 +808,18 @@ impl Game<'_> { out.entry(chunk_of(at)).or_default().push(tag); } } + for c in &self.cushions { + if keys.contains(&chunk_of(c.position)) { + let uuid = self + .persist + .uuids + .get(&c.id) + .copied() + .unwrap_or_else(random_uuid); + let tag = entity_nbt::cushion_to_nbt(c, uuid, self.persist.raw.get(&c.id)); + out.entry(chunk_of(c.position)).or_default().push(tag); + } + } for p in &self.paintings { let at = painting_center(p); if keys.contains(&chunk_of(at)) { @@ -868,6 +912,13 @@ impl Game<'_> { } keep }); + self.cushions.retain(|c| { + let keep = !gone(c.position); + if !keep { + removed.push(c.id); + } + keep + }); // Riders leave with their root vehicle, a boat's with the boat. let leaving: HashSet = (0..self.mobs.len()) .filter(|&i| { @@ -934,6 +985,7 @@ impl Game<'_> { .chain(self.boats.iter().map(|e| e.id)) .chain(self.carts.iter().map(|e| e.id)) .chain(self.armor_stands.iter().map(|e| e.id)) + .chain(self.cushions.iter().map(|e| e.id)) .chain(self.item_frames.iter().map(|e| e.id)) .chain(self.paintings.iter().map(|e| e.id)) .chain(self.others.iter().map(|e| e.id)) diff --git a/crates/horizon-client/src/game/pickups.rs b/crates/horizon-client/src/game/pickups.rs index 1e19bcfc3..040a6f01b 100644 --- a/crates/horizon-client/src/game/pickups.rs +++ b/crates/horizon-client/src/game/pickups.rs @@ -155,6 +155,8 @@ mod tests { value: 1, age: 0, on_ground: false, + count: 1, + ticks: 0, })) } diff --git a/crates/horizon-client/src/game/piglins.rs b/crates/horizon-client/src/game/piglins.rs index bcf9e6ab5..edfd9a3a0 100644 --- a/crates/horizon-client/src/game/piglins.rs +++ b/crates/horizon-client/src/game/piglins.rs @@ -4,18 +4,172 @@ use super::Game; use horizon_client::{ items::ItemStack, - mobs::{Species, Who, piglin::ADMIRE}, + mobs::{ + Mob, Species, Who, + finalize::{ + SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments, + stored_drop_chance, + }, + piglin::{ADMIRE, ATE_RECENTLY}, + }, }; use horizon_core::{geometry::Vec3, random::RandomSource}; impl Game<'_> { - fn piglin_loved(&self, item: &str) -> bool { + fn item_in_tag(&self, tag: &str, item: &str) -> bool { self.res .items() .item_tags - .get("minecraft:piglin_loved") + .get(tag) .is_some_and(|t| t.contains(item)) } + fn piglin_loved(&self, item: &str) -> bool { + self.item_in_tag("minecraft:piglin_loved", item) + } + /// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`). + fn piglin_wanted(&self, item: &str) -> bool { + self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item) + } + /// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes + /// to its slot, anything else to the main hand. + fn piglin_slot(&self, item: &str) -> usize { + let slot = self + .res + .items() + .get(item) + .and_then(|i| i.equip_slot.as_deref()) + .filter(|s| matches!(*s, "head" | "chest" | "legs" | "feet")) + .unwrap_or("mainhand"); + SLOT_NAMES.iter().position(|n| *n == slot).unwrap_or(0) + } + /// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a wanted + /// item replaces an unwanted one and never the other way round. Vanilla's + /// armour and weapon comparison for two equal-interest items is left out + /// (the piglin keeps what it has). + fn piglin_can_replace(&self, m: &Mob, item: &str, slot: usize) -> bool { + let Some(current) = horizon_client::mobs::finalize::item(m, slot) else { + return true; + }; + self.piglin_wanted(item) && !self.piglin_wanted(¤t) + } + /// `SimpleContainer.canAddItem` on the piglin's eight slots. + fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool { + m.extra.inventory.len() < 8 + || m.extra.inventory.iter().any(|s| { + s.same_item_same_components(stack) && s.count < self.piglin_max_stack(&s.item) + }) + } + fn piglin_max_stack(&self, item: &str) -> u32 { + self.res.items().get(item).map_or(64, |i| i.max_stack) + } + /// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without + /// the baby rule, which babies skip entirely here. + fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool { + let item = &*stack.item; + if self.item_in_tag("minecraft:piglin_repellents", item) { + return false; + } + let holds_loved = m + .extra + .off_hand + .as_deref() + .is_some_and(|o| self.piglin_loved(o)); + if item == "minecraft:gold_ingot" { + return !holds_loved; + } + let space = self.piglin_can_add(m, stack); + if item == "minecraft:gold_nugget" { + space + } else if self.item_in_tag("minecraft:piglin_food", item) { + m.extra.ints[0] <= 0 && space + } else if !self.piglin_loved(item) { + self.piglin_can_replace(m, item, self.piglin_slot(item)) + } else { + !holds_loved && space + } + } + /// `Mob.equipItemIfPossible` for a piglin: put it in its slot when that + /// slot takes it (an armour piece that cannot replace what is worn goes + /// to an empty main hand), dropping what it replaces by drop chance. + fn piglin_equip(&mut self, index: usize, stack: &ItemStack) -> bool { + let item = &*stack.item; + let mut slot = self.piglin_slot(item); + let m = &self.mobs[index]; + let mut replace = self.piglin_can_replace(m, item, slot); + if slot > 1 && !replace { + slot = 0; + replace = m.extra.main_hand.is_none(); + } + if !replace { + return false; + } + let m = &mut self.mobs[index]; + let old = horizon_client::mobs::finalize::item(m, slot); + let chance = stored_drop_chance(m, slot); + let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance; + let old_enchantments = slot_enchantments(m, slot); + set_item(m, slot, item); + // The piglin keeps what the stack carried (Protection IV stays on). + set_slot_enchantments(m, slot, stack.enchantments().to_vec()); + // setItemSlotAndDropWhenKilled, then persistenceRequired. + set_drop_chance(m, slot, 2.0); + m.persistent = true; + let at = [m.position.x, m.position.y, m.position.z]; + if let (true, Some(old)) = (drop_old, old) { + let mut dropped = ItemStack::new(&old, 1); + dropped.set_crafting_enchantments(old_enchantments); + self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10); + } + true + } + /// `PiglinAi.putInInventory`: what does not fit is thrown at a random + /// spot nearby. + fn piglin_stash(&mut self, index: usize, mut stack: ItemStack) { + let max = self.piglin_max_stack(&stack.item); + let m = &mut self.mobs[index]; + for s in &mut m.extra.inventory { + if stack.count > 0 && s.same_item_same_components(&stack) && s.count < max { + let n = (max - s.count).min(stack.count); + s.count += n; + stack.count -= n; + } + } + while stack.count > 0 && m.extra.inventory.len() < 8 { + let n = stack.count.min(max); + m.extra.inventory.push(ItemStack { + count: n, + ..stack.clone() + }); + stack.count -= n; + } + if stack.count == 0 { + return; + } + let from = [m.position.x, m.position.y + 1.3, m.position.z]; + let r = &mut self.world.random; + let to = [ + from[0] + f64::from(r.next_float() * 8.0 - 4.0), + from[1], + from[2] + f64::from(r.next_float() * 8.0 - 4.0), + ]; + self.piglin_throw(from, to, stack); + } + /// `PiglinAi.stopHoldingOffHandItem` for something that is not + /// bartering currency: wear it if it can, else keep it in the inventory. + fn piglin_keep(&mut self, index: usize, stack: ItemStack) { + // Nuggets keep their whole stack in the inventory (a hand slot here + // holds one item id only). + if &*stack.item == "minecraft:gold_nugget" || !self.piglin_equip(index, &stack) { + self.piglin_stash(index, stack); + } + } + /// `BehaviorUtils.throwItem`: toss a stack from a point toward another. + fn piglin_throw(&mut self, from: [f64; 3], to: [f64; 3], stack: ItemStack) { + let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6); + let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3]; + self.spawn_item(stack, from, v, 40); + } /// `PiglinAi.isWearingSafeArmor`. pub(super) fn wearing_gold(&self) -> bool { let tag = self @@ -28,15 +182,13 @@ impl Game<'_> { .flatten() .any(|s| tag.is_some_and(|t| t.contains(&*s.item))) } - /// Piglins notice gold on the ground within 9 blocks, walk to it and - /// take it (`GoToWantedItem`, `Piglin.pickUpItem`). + /// Piglins notice wanted items on the ground within 9 blocks (`PiglinAi.wantsToPickup`: + /// gold and other loved items, nuggets, food, gear), walk to them and + /// take them (`GoToWantedItem`, `Piglin.pickUpItem`). pub(super) fn piglin_pickups(&mut self) { if !self.ui.rules.bool("mob_griefing") { return; } - // Whether each item is piglin-loved, kept until an item is added or - // removed (added ones are pushed at the end under a new id). - let mut loved: (Option<(usize, Option)>, Vec) = (None, Vec::new()); for i in 0..self.mobs.len() { let m = &self.mobs[i]; if m.species() != Species::Piglin @@ -44,27 +196,17 @@ impl Game<'_> { || m.is_baby() || m.extra.using_item > 0 || m.extra.egg_time > 0 - || m.extra.off_hand.is_some() { continue; } let pos = m.position; - let key = (self.dropped.len(), self.dropped.last().map(|e| e.id)); - if loved.0 != Some(key) { - loved.0 = Some(key); - loved.1 = self - .dropped - .iter() - .map(|e| self.piglin_loved(&e.stack.item)) - .collect(); - } let best = self .dropped .iter() .enumerate() - .filter(|&(j, _)| loved.1[j]) .map(|(j, e)| (j, e.position.distance_squared(pos))) .filter(|(_, d)| *d <= 81.0) + .filter(|&(j, _)| self.piglin_wants(m, &self.dropped[j].stack)) .min_by(|a, b| a.1.total_cmp(&b.1)); let Some((j, _)) = best else { self.mobs[i].extra.point = None; @@ -79,24 +221,38 @@ impl Game<'_> { continue; } // Nuggets come whole, anything else one at a time. - let item = e.stack.item.clone(); let (taken_item, id) = (e.clone(), self.mobs[i].id); + let item = e.stack.item.clone(); self.add_pickup( super::pickups::Taken::Item(Box::new(taken_item)), super::pickups::Collector::Mob(id), ); - let taken = if &*item == "minecraft:gold_nugget" { - self.dropped.remove(j).stack.count + let stack = if &*item == "minecraft:gold_nugget" { + self.dropped.remove(j).stack } else { let e = &mut self.dropped[j]; e.stack.count -= 1; + let one = ItemStack { + count: 1, + ..e.stack.clone() + }; if e.stack.count == 0 { self.dropped.remove(j); } - 1 + one }; - let _ = taken; - self.start_admiring(i, item); + if self.piglin_loved(&item) { + self.start_admiring(i, item); + } else if self.item_in_tag("minecraft:piglin_food", &item) + && self.mobs[i].extra.ints[0] <= 0 + { + // PiglinAi.eat: the food is gone and it will not eat for a while. + self.mobs[i].extra.ints[0] = ATE_RECENTLY; + self.mobs[i].nav_stop(); + } else { + self.mobs[i].nav_stop(); + self.piglin_keep(i, stack); + } } } fn start_admiring(&mut self, index: usize, item: std::sync::Arc) { @@ -109,14 +265,15 @@ impl Game<'_> { self.audio .play("minecraft:entity.piglin.admiring_item", Some(at), 1.0, 1.0); } - /// `PiglinAi.mobInteract`: hand a piglin something it loves. + /// `PiglinAi.mobInteract`: hand a piglin a gold ingot (only piglin + /// currency is accepted from the hand; other loved items are passed over). pub(super) fn give_piglin(&mut self, index: usize, item: &str) -> bool { let m = &self.mobs[index]; if m.species() != Species::Piglin || m.is_baby() || m.extra.using_item > 0 || m.extra.egg_time > 0 - || !self.piglin_loved(item) + || item != "minecraft:gold_ingot" { return false; } @@ -124,7 +281,8 @@ impl Game<'_> { true } /// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a - /// roll of the bartering table, thrown toward the player. + /// roll of the bartering table, thrown toward the player; any other loved + /// item it picked up is kept (worn if it can be, else stored). pub(super) fn finish_admiring(&mut self, mob: u32) { let Some(i) = self.mobs.iter().position(|m| m.id == mob) else { return; @@ -132,9 +290,13 @@ impl Game<'_> { let Some(item) = self.mobs[i].extra.off_hand.take() else { return; }; + if &*item != "minecraft:gold_ingot" { + self.piglin_keep(i, ItemStack::new(&item, 1)); + return; + } let m = &self.mobs[i]; let from = [m.position.x, m.position.y + 1.3, m.position.z]; - let items = if &*item == "minecraft:gold_ingot" && !m.is_baby() { + let items = if !m.is_baby() { self.res.items().loot_table( "minecraft:gameplay/piglin_bartering", false, @@ -176,10 +338,7 @@ impl Game<'_> { from[2] + f64::from(r.next_float() * 8.0 - 4.0), ] }); - let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; - let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6); - let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3]; - self.spawn_item(stack, from, v, 40); + self.piglin_throw(from, to, stack); } } /// `PiglinAi.angerNearbyPiglins`. diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index b28c2fb4d..2ccfd4116 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -71,6 +71,8 @@ pub(super) struct BodyPotions { pub(super) struct PotionState { pub(super) effects: PlayerEffects, thrown: Vec, + /// The area effect clouds a server tracks (game/remote_projectiles.rs). + pub(super) clouds: Vec, applied: Vec<(&'static str, &'static str, f64, &'static str)>, /// Armor the protection table was computed for. protection_for: Option>>, @@ -141,14 +143,22 @@ impl PotionState { t.stack = stack; } } - /// One tick of a server's client: `step` moves each toward where the - /// server put it. - pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) { - for t in &mut self.thrown { - t.previous = t.position; - t.age += 1; - t.position = step(t.id, t.position); - } + /// A server's move: thrown potions have no interpolation handler, so + /// `moveOrInterpolateTo` puts them there at once. False when `id` isn't one. + pub(super) fn move_remote_thrown(&mut self, id: u32, at: Vec3) -> bool { + let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else { + return false; + }; + t.position = at; + true + } + /// `handleSetEntityMotion` for a thrown potion. False when `id` isn't one. + pub(super) fn remote_thrown_motion(&mut self, id: u32, velocity: Vec3) -> bool { + let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else { + return false; + }; + t.velocity = velocity; + true } pub(super) fn remove_remote_thrown(&mut self, id: u32) { self.thrown.retain(|t| t.id != id); @@ -266,7 +276,8 @@ impl Game<'_> { } else if let Some(rest) = self.give(filled) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40); + // `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } /// Drinking a potion while right-click is held; true when handled. @@ -390,8 +401,28 @@ impl Game<'_> { for action in actions { self.apply_effect_action(action); } + self.turtle_helmet_tick(); self.apply_effect_status(); } + /// `Player.tick` -> `turtleHelmetTick`: with a turtle helmet on the head + /// and the eyes out of the water, Water Breathing for 200 ticks (no + /// particles, an icon). + fn turtle_helmet_tick(&mut self) { + let Some(p) = self.player.as_ref() else { + return; + }; + let eye_y = p.position.y + f64::from(p.dimensions.eye_height); + if !p.health.dead_or_dying() + && self.armor[0] + .as_ref() + .is_some_and(|s| &*s.item == "minecraft:turtle_helmet") + && !self.eye_in_water(p.position.x, eye_y, p.position.z) + { + let mut effect = EffectInstance::new("water_breathing", 200, 0); + effect.visible = false; + self.potions.effects.add(effect); + } + } /// A client's own effects (`LivingEntity.tickEffects` on the client, /// `MobEffectInstance.tickClient`): they count down, and the server /// removes them. Their modifiers and flags move the predicted player. @@ -484,6 +515,7 @@ impl Game<'_> { thrown.retain_mut(|t| { t.age += 1; t.previous = t.position; + self.thrown_potion_motion(t); let next = Vec3::new( t.position.x + t.velocity.x, t.position.y + t.velocity.y, @@ -520,18 +552,6 @@ impl Game<'_> { return false; } t.position = next; - // ThrowableItemProjectile: drag 0.99 then gravity 0.05. - // ThrownExperienceBottle.getDefaultGravity is 0.07. - let gravity = if &*t.stack.item == "minecraft:experience_bottle" { - 0.07 - } else { - 0.05 - }; - t.velocity = Vec3::new( - t.velocity.x * 0.99, - t.velocity.y * 0.99 - gravity, - t.velocity.z * 0.99, - ); t.age < 1200 && t.position.y > -128.0 }); self.potions.thrown = thrown; @@ -539,6 +559,60 @@ impl Game<'_> { self.splash(&stack, at); } } + /// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which + /// come before the move: gravity 0.05 (0.07 for an experience bottle), + /// then drag 0.99 (0.8 in water). + fn thrown_potion_motion(&self, t: &mut ThrownPotion) { + let gravity = if &*t.stack.item == "minecraft:experience_bottle" { + 0.07 + } else { + 0.05 + }; + let at = t.position; + let water = self + .res + .facts() + .get(self.world.block( + at.x.floor() as i32, + at.y.floor() as i32, + at.z.floor() as i32, + )) + .fluid + == horizon_client::blocks::Fluid::Water; + let inertia = if water { 0.8 } else { 0.99 }; + t.velocity = Vec3::new( + t.velocity.x * inertia, + (t.velocity.y - gravity) * inertia, + t.velocity.z * inertia, + ); + } + /// `ThrowableProjectile.tick` on a server's client: it falls and moves + /// by itself between the server's updates and stops at a block; only + /// the server breaks it. + pub(super) fn step_remote_thrown(&mut self) { + let mut thrown = std::mem::take(&mut self.potions.thrown); + for t in &mut thrown { + t.previous = t.position; + t.age += 1; + self.thrown_potion_motion(t); + let next = Vec3::new( + t.position.x + t.velocity.x, + t.position.y + t.velocity.y, + t.position.z + t.velocity.z, + ); + let state = self.world.block( + next.x.floor() as i32, + next.y.floor() as i32, + next.z.floor() as i32, + ); + if self.res.facts().get(state).full_collision { + t.velocity = Vec3::ZERO; + } else { + t.position = next; + } + } + self.potions.thrown = thrown; + } /// `ThrownItemRenderer` for thrown splash and lingering potions and /// experience bottles: the item (GROUND) facing the camera at scale 1, /// lit at the light probe (the eye, 0.85 of the 0.25 height). diff --git a/crates/horizon-client/src/game/raids.rs b/crates/horizon-client/src/game/raids.rs index bedca5708..743f02f44 100644 --- a/crates/horizon-client/src/game/raids.rs +++ b/crates/horizon-client/src/game/raids.rs @@ -796,6 +796,8 @@ impl Game<'_> { let s = m.extra.raider.get_or_insert_with(Box::default); s.rider = has_rider; s.in_village = in_village; + // Vindicator.customServerAiStep: `ServerLevel.isRaided`. + s.raided = species == Species::Vindicator && raids.nearby(block).is_some(); if s.raid_id.is_some_and(|id| raids.get(id).is_none()) { s.raid_id = None; s.ticks_outside_raid = 0; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index bc199b24b..945af4585 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -6,11 +6,12 @@ //! clicks). The player still moves with the local physics, as the vanilla //! client predicts its own movement; the server stays authoritative for //! everything else. +use super::remote_chunks::{ChunkDecoder, ChunkFormat, Next}; use super::{Dim, Game, Mining, Screen}; use horizon_client::first_person::{Hand, SwingAnimation}; use horizon_client::{ Result, - chunk::{BIOME_STRATEGY, Chunk, Light, MIN_Y, SECTIONS, Section, block_strategy}, + chunk::Chunk, containers::{BlockEntity, Furnace, FurnaceKind}, entities::{Experience, ExperienceOrb, ItemEntity}, hud::{HudBatch, rgba}, @@ -25,11 +26,7 @@ use horizon_client::{ tab_list::SPECTATOR, world::World, }; -use horizon_core::{ - geometry::Vec3, - paletted::{PalettedContainer, Strategy}, - random::RandomSource, -}; +use horizon_core::{geometry::Vec3, random::RandomSource}; use horizon_protocol::{ entity_data::{ITEM_ENTITY_ITEM, Value}, item::{Hashed, Stack}, @@ -64,9 +61,6 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text { remote_text(tag).resolve_legacy() } -/// A section's sky and block light layers from the wire. -type LightLayers = (Option>, Option>); - /// Streaming needs a generator; a server world never generates. struct NoTerrain; impl ChunkGenerator for NoTerrain { @@ -104,9 +98,19 @@ fn place_interpolated(m: &mut Mob, at: PosRot) { m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0); } +/// What the network thread delivered, in order: an event, or a chunk packet +/// handed to the decoder (with the format it decodes with), whose column +/// comes back from `ChunkDecoder::next`. +enum Received { + Event(Event), + Chunk(Arc), +} + pub(super) struct Remote { pub session: Session, pub brand: String, + /// The server announced it runs mods' quiet commands. + pub quiet_commands: bool, pub(super) builtin: Arc, lang: Arc, pub(super) registries: Arc, @@ -114,8 +118,11 @@ pub(super) struct Remote { status: String, pub(super) entity_id: i32, dimension: DimensionType, - biome_map: Vec, - biome_bits: u8, + /// What chunk packets decode with (the dimension's height and sky, the + /// biome registry); replaced whenever one of them changes. + pub(super) chunk_format: Arc, + /// Decodes chunk packets as they arrive, off the frame. + decoder: ChunkDecoder, view_distance: i32, sequence: i32, position_sender: horizon_client::player_sync::PositionSender, @@ -200,7 +207,7 @@ pub(super) struct Remote { chunk_center: (i32, i32), /// Received events a frame left for the next one, in order, and when /// the oldest of them first waited (`poll_remote`). - backlog: VecDeque, + backlog: VecDeque, backlog_since: Option, /// `ClientboundTransferPacket`'s target: when the connection ends the /// game connects here, the stored cookies carried over (`TransferState`). @@ -210,11 +217,12 @@ pub(super) struct Remote { /// The time a frame spends on chunk packets before it leaves the rest of /// the received events for the next frame. A batch of 25 to 60 columns /// (0.1 ms each on an M4 Pro) all in one frame was a 3 to 8 ms frame every -/// time the player crossed a chunk border at 32 chunks. +/// time the player crossed a chunk border at 32 chunks; they decode on the +/// chunk decoder's thread now, and the frame only inserts them. const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1); /// Events that have waited this long are all handled in the next frame, -/// whatever their cost: the packets after a slow machine's chunks never -/// wait more than a tick. +/// whatever inserting their columns costs, up to a column still decoding: +/// the packets after a slow machine's chunks don't wait on its frames. const BACKLOG_LIMIT: Duration = Duration::from_millis(50); /// The open menu name of a server's horse inventory, then its chest's /// columns (the packet names no `MenuType`). @@ -413,6 +421,7 @@ impl Game<'_> { self.remote = Some(Remote { session, brand: "unknown".into(), + quiet_commands: false, builtin: Arc::new(builtin), lang, registries: Arc::new(Dynamic::default()), @@ -420,8 +429,12 @@ impl Game<'_> { status: "Connecting to the server...".into(), entity_id: 0, dimension: DimensionType::vanilla("minecraft:overworld"), - biome_map: Vec::new(), - biome_bits: 7, + chunk_format: Arc::new(ChunkFormat::new( + &DimensionType::vanilla("minecraft:overworld"), + Vec::new(), + 7, + )), + decoder: ChunkDecoder::new(), view_distance: self.settings.render_distance, sequence: 0, position_sender: Default::default(), @@ -741,25 +754,63 @@ impl Game<'_> { /// client's packet processor), in order. Chunk packets get /// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from /// the next chunk packet on wait for the next frame, unless they have - /// already waited `BACKLOG_LIMIT`. + /// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds + /// back what follows it. pub(super) fn poll_remote(&mut self) -> Result<()> { let Some(r) = self.remote.as_mut() else { return Ok(()); }; let mut events = std::mem::take(&mut r.backlog); - events.extend(r.session.poll()); + // Chunk packets start decoding now; the frame that reaches one in + // order inserts its column. + for event in r.session.poll() { + events.push_back(match event { + Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => { + match r.decoder.submit(r.chunk_format.clone(), p) { + None => Received::Chunk(r.chunk_format.clone()), + Some(p) => Received::Event(Event::Packet(p)), + } + } + event => Received::Event(event), + }); + } let overdue = r .backlog_since .is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT); let mut chunks = Duration::ZERO; - while let Some(event) = events.pop_front() { - let chunk = - matches!(&event, Event::Packet(p) if matches!(**p, C::LevelChunkWithLight(_))); + while let Some(item) = events.pop_front() { + let chunk = matches!(item, Received::Chunk(_)); if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME { - events.push_front(event); + events.push_front(item); break; } let started = chunk.then(Instant::now); + let event = match item { + Received::Event(event) => event, + Received::Chunk(format) => { + // A column still decoding waits for the next frame, with + // what came after it, overdue or not. + let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else { + break; + }; + let (packet, column) = match next { + Next::Ready(decoded) => decoded, + Next::Decoding => { + events.push_front(Received::Chunk(format)); + break; + } + Next::Lost => { + log::warn!("Horizon: the chunk decoder stopped; a column is missing"); + continue; + } + }; + if let C::LevelChunkWithLight(p) = *packet { + self.remote_chunk(&p, Some((&format, column))); + } + chunks += started.map_or(Duration::ZERO, |t| t.elapsed()); + continue; + } + }; match event { Event::Status(s) => { if let Some(r) = self.remote.as_mut() { @@ -791,7 +842,15 @@ impl Game<'_> { remote.brand = brand; } } - Event::Reconfigured(registries) => self.set_registries(registries), + Event::QuietCommands => { + if let Some(remote) = &mut self.remote { + remote.quiet_commands = true; + } + } + Event::Reconfigured(registries) => { + self.boss_overlay.reset(); + self.set_registries(registries); + } // handleTransfer: "Cannot transfer to server from // singleplayer" (no ServerData), so the world just closes. Event::Transfer { host, port } => { @@ -915,8 +974,8 @@ impl Game<'_> { }) .unwrap_or_default(); if let Some(r) = self.remote.as_mut() { - r.biome_bits = ceil_log2(biomes.len()); - r.biome_map = biomes; + let bits = ceil_log2(biomes.len()); + r.chunk_format = Arc::new(ChunkFormat::new(&r.dimension, biomes, bits)); r.registries = registries; } } @@ -927,6 +986,7 @@ impl Game<'_> { }; let dimension = r.registries.dimension(spawn.dimension_type); let same = r.dimension.name == dimension.name && keep_world; + r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension)); r.dimension = dimension; r.game_mode = spawn.game_type; r.loaded_sent = false; @@ -949,6 +1009,9 @@ impl Game<'_> { self.world.day_time, self.world.weather, ); + let old = std::mem::take(&mut self.world.chunks); + let count = old.len(); + horizon_client::chunk::free_columns(old, count); self.world = World::new(self.res.facts().air, spawn.seed); self.world.game_time = time; self.world.day_time = day; @@ -989,145 +1052,10 @@ impl Game<'_> { self.apply_settings(); } - /// LevelChunkSection data into the client's fixed -64..320 column. - fn remote_chunk(&self, p: &play::LevelChunkWithLight) -> std::result::Result { - let r = self.remote.as_ref().ok_or("no connection")?; - let sections = p - .chunk - .sections(r.dimension.sections(), 16, r.biome_bits) - .map_err(|e| e.to_string())?; - let mut chunk = Chunk::empty(p.x, p.z); - let offset = (r.dimension.min_y - MIN_Y).div_euclid(16); - // Biome table index per quart cell of the dimension's own sections. - let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len()); - for (i, s) in sections.iter().enumerate() { - let target = offset + i as i32; - let mut cells = [0u32; 64]; - for (j, cell) in cells.iter_mut().enumerate() { - *cell = u32::from( - r.biome_map - .get(s.biomes.get(j) as usize) - .copied() - .unwrap_or(0), - ); - } - biome_cells.push(cells); - if !(0..SECTIONS as i32).contains(&target) { - continue; - } - // A global section is as wide as the server's registry: 16 bits - // from a vanilla server, wider from a Horizon host with the same - // content mods. Read it at that width, then widen it to ours. - let ours = block_strategy(); - let strategy = if ours.wire_has_palette(s.states.bits) { - ours - } else { - Strategy::blocks(s.states.bits) - }; - let palette = s.states.palette.iter().map(|&p| p as u32).collect(); - let words = s.states.data.iter().map(|&w| w as u64).collect(); - let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words) - .map_err(|e| e.to_string())?; - if strategy.global_bits != ours.global_bits { - let mut values = [0u32; 4096]; - blocks.unpack(&mut values); - // Ids past our registry (a host with mods we lack) read as air. - let limit = 1u64 << ours.global_bits; - for v in &mut values { - if u64::from(*v) >= limit { - *v = 0; - } - } - blocks = PalettedContainer::from_values(ours, &values); - } - let mut section = Section { - blocks, - biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells), - light: Light::Uniform(0xF0), - }; - section.compact(); - chunk.sections[target as usize] = Arc::new(section); - } - // Sections outside the dimension's range take its nearest biomes - // (the column's top or bottom cell layer repeated). - if !biome_cells.is_empty() { - let last = biome_cells.len() - 1; - for si in 0..SECTIONS as i32 { - let i = si - offset; - if (0..sections.len() as i32).contains(&i) { - continue; - } - let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) }; - let mut cells = [0u32; 64]; - for (j, cell) in cells.iter_mut().enumerate() { - *cell = biome_cells[edge][layer * 16 + j % 16]; - } - Arc::make_mut(&mut chunk.sections[si as usize]).biomes = - PalettedContainer::from_values(BIOME_STRATEGY, &cells); - } - } - // ClientPacketListener.applyLightData: the column keeps the light - // the server sent; nothing is relit. - let (layers, above) = self.wire_light(&p.light); - let sky = r.dimension.has_skylight; - horizon_client::light::light_from_layers(&mut chunk, &layers, above, sky); - Ok(chunk) - } - /// `ClientPacketListener.readSectionList`: the sky and block layers of a - /// light packet, by section of the client's column, and the sky light - /// above the column's top section (from the light section just above - /// it, else open sky). - fn wire_light( - &self, - light: &horizon_protocol::chunk::LightData, - ) -> (Vec, [u8; 256]) { - let mut layers: Vec = vec![(None, None); SECTIONS]; - let Some(r) = self.remote.as_ref() else { - return (layers, [0; 256]); - }; - let sky = r.dimension.has_skylight; - let mut above = [if sky { 15 } else { 0 }; 256]; - // Light section b is the dimension's section b - 1. - let first = (r.dimension.min_y - MIN_Y).div_euclid(16) - 1; - for (b, layer) in light - .layers(r.dimension.sections() + 2) - .into_iter() - .enumerate() - { - let si = first + b as i32; - if (0..SECTIONS as i32).contains(&si) { - layers[si as usize] = layer; - } else if si == SECTIONS as i32 - && sky - && let Some(l) = &layer.0 - { - for (i, a) in above.iter_mut().enumerate() { - *a = (l[i >> 1] >> ((i & 1) * 4)) & 15; - } - } - } - (layers, above) - } - /// `handleLightUpdatePacket`: the sections it names take its light. - fn remote_light_update(&mut self, p: &play::LightUpdate) { - let Some(sky) = self.remote.as_ref().map(|r| r.dimension.has_skylight) else { - return; - }; - if !self.world.chunks.contains_key(&(p.x, p.z)) { - return; - } - let (layers, _) = self.wire_light(&p.light); - let Some(chunk) = self.world.chunks.get_mut(&(p.x, p.z)) else { - return; - }; - let changed = - horizon_client::light::update_light_layers(Arc::make_mut(chunk), &layers, sky); - Streaming::light_updated(&mut self.world, p.x, p.z, &changed); - } /// `ClientChunkCache.Storage.inRange`: within the storage around the /// view centre, `calculateStorageRange` (the server's radius, at least /// two, plus three) chunks each way. - fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool { + pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool { self.remote.as_ref().is_none_or(|r| { let range = r.view_distance.max(2) + 3; (x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range @@ -1135,29 +1063,33 @@ impl Game<'_> { } /// The columns a moved or resized storage no longer holds. fn drop_remote_chunks_out_of_range(&mut self) { - let gone: Vec<(i32, i32)> = self + let gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self .world .chunks .keys() .copied() .filter(|&(x, z)| !self.remote_chunk_in_range(x, z)) .collect(); - for (x, z) in gone { - self.streaming.forget(&mut self.world, x, z); - self.world - .block_entities - .retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z)); - if let Some(renderer) = self.renderer.as_mut() { - for sy in 0..SECTIONS as i32 { - renderer.remove((x, sy, z)); - } - } + if gone.is_empty() { + return; } + let mut freed = Vec::with_capacity(gone.len()); + for &(x, z) in &gone { + freed.extend(self.streaming.forget(&mut self.world, x, z)); + } + if let Some(renderer) = self.renderer.as_mut() { + renderer.remove_columns(gone.iter().copied()); + } + horizon_client::chunk::free_columns(freed, gone.len()); + // One pass for all of them. + self.world + .block_entities + .retain(|k, _| !gone.contains(&(k.0 >> 4, k.2 >> 4))); } /// `handleBlockEntityData`: the block entity at `pos`, when it is of the /// packet's type, loads the tag (`loadWithComponents`); an empty tag /// changes nothing. - fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) { + pub(super) fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) { let key = (pos[0], pos[1], pos[2]); let Some(name) = self .remote @@ -1248,7 +1180,7 @@ impl Game<'_> { } } /// Keep the renderable block entities (chests, furnaces) in step with blocks. - fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) { + pub(super) fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) { let name = self.res.facts().get(state).name.clone(); match BlockEntity::for_block(&name) { Some(entity) => { @@ -1337,6 +1269,9 @@ impl Game<'_> { } else { self.res.mob_types().get(kind).cloned() }; + if kind == "minecraft:ender_dragon" { + self.spawn_remote_dragon(id, position, yaw); + } if kind == "minecraft:experience_orb" { // Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData. self.orbs.retain(|o| o.id != id as u32); @@ -1348,6 +1283,8 @@ impl Game<'_> { value: 1, age: 0, on_ground: false, + count: 1, + ticks: 0, }); } if let Some(t) = mob_type { @@ -1443,13 +1380,17 @@ impl Game<'_> { m.on_ground = on_ground; return; } - if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| { - p.id == id as u32 - && matches!( - p.kind, - horizon_client::projectiles::ProjectileKind::LargeFireball { .. } - ) - }) { + // Projectiles keep Entity's NO_OP InterpolationHandler too: the + // server's position replaces theirs at once and their own tick + // flies them on (`tick_remote_projectiles`). Eased towards it + // instead, they only moved when an update came, every 10 to 20 + // ticks. + if let Some(p) = self + .creatures + .projectiles + .iter_mut() + .find(|p| p.id == id as u32) + { if path.is_some() { p.position = Vec3::new(position[0], position[1], position[2]); } @@ -1459,6 +1400,22 @@ impl Game<'_> { } return; } + if path.is_some() + && (self.potions.move_remote_thrown( + id as u32, + Vec3::new(position[0], position[1], position[2]), + ) || self.end.move_remote_eye(id as u32, position)) + { + return; + } + // A dragon fireball keeps Entity's NO_OP InterpolationHandler too: + // the server's position replaces its own at once. + if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) { + if path.is_some() { + b.position = Vec3::new(position[0], position[1], position[2]); + } + return; + } if let Some(p) = r.pending_items.get_mut(&id) { p.0 = position; } @@ -1505,6 +1462,8 @@ impl Game<'_> { self.mobs.retain(|m| m.id != id as u32); self.orbs.retain(|o| o.id != id as u32); self.dropped.retain(|d| d.id != id as u32); + // Before the crystals go: the dragon's beam index follows them. + self.remove_remote_dragon(id); self.remove_remote_object(id as u32); self.remove_remote_projectile(id as u32); if let Some(r) = self.remote.as_mut() { @@ -1704,36 +1663,7 @@ impl Game<'_> { }) .clamp(-90.0, 90.0); } - // ClientChunkCache.replaceWithPacketData: a column outside the - // storage around the view centre is ignored. - C::LevelChunkWithLight(p) if !self.remote_chunk_in_range(p.x, p.z) => { - log::warn!( - "Ignoring chunk since it's not in the view range: {}, {}", - p.x, - p.z - ); - } - C::LevelChunkWithLight(p) => match self.remote_chunk(&p) { - Ok(chunk) => { - let (x, z) = (p.x, p.z); - self.streaming.insert_remote(&mut self.world, chunk); - self.world - .block_entities - .retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z)); - for info in &p.chunk.block_entities { - let pos = ( - x * 16 + i32::from(info.packed_xz as u8 >> 4), - i32::from(info.y), - z * 16 + i32::from(info.packed_xz as u8 & 15), - ); - let state = self.world.block(pos.0, pos.1, pos.2); - self.sync_block_entity(pos, state); - // LevelChunk.replaceWithPacketData: the block entity loads its update tag. - self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag); - } - } - Err(e) => log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z), - }, + C::LevelChunkWithLight(p) => self.remote_chunk(&p, None), C::LightUpdate(p) => self.remote_light_update(&p), C::ForgetLevelChunk(p) => { let [x, z] = p.pos; @@ -1742,9 +1672,7 @@ impl Game<'_> { .block_entities .retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z)); if let Some(renderer) = self.renderer.as_mut() { - for sy in 0..SECTIONS as i32 { - renderer.remove((x, sy, z)); - } + renderer.remove_columns([(x, z)]); } } C::ChunkBatchFinished(_) => { @@ -1771,19 +1699,30 @@ impl Game<'_> { } } C::BlockChangedAck(p) => self.end_predictions(p.sequence), - // ClientLevel.blockEvent: BlockEntity.triggerEvent (chest lids, - // shulker boxes, bells) when the block is still the one it names. + // Level.blockEvent on the client level: the block's triggerEvent + // runs on the client's copy of whatever is at pos (a piston's + // moveBlocks builds the moving block entities it animates, a note + // block plays); what it doesn't handle reaches the client copy of + // the block entity (chest lids, shulker boxes). C::BlockEvent(p) => { - let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]); - let block = &self.res.facts().get(state).name; - let named = self.remote.as_ref().and_then(|r| { - r.builtin - .get("minecraft:block")? - .name(p.block) - .map(str::to_owned) + let pos = horizon_core::position::BlockPos::new(p.pos[0], p.pos[1], p.pos[2]); + let triggered = self.with_level(|b, level, ticks| { + b.client_block_event(level, ticks, pos, i32::from(p.b0), i32::from(p.b1)) }); - if named.as_deref() == Some(&**block) { - self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1)); + self.sync_moving_pistons(); + self.process_level_events()?; + if !triggered { + let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]); + let block = &self.res.facts().get(state).name; + let named = self.remote.as_ref().and_then(|r| { + r.builtin + .get("minecraft:block")? + .name(p.block) + .map(str::to_owned) + }); + if named.as_deref() == Some(&**block) { + self.block_entity_event(p.pos, i32::from(p.b0), i32::from(p.b1)); + } } } // ClientLevel.levelEvent: LevelEventHandler, as this client's own @@ -2068,6 +2007,7 @@ impl Game<'_> { } self.remote_object_data(p.id, &p.values); self.remote_projectile_data(p.id, &p.values); + self.remote_dragon_data(p.id, &p.values); } // ClientPacketListener.handleSetEquipment: what a mob or another // player holds and wears (EquipmentSlot ordinals). @@ -2126,7 +2066,10 @@ impl Game<'_> { { self.add_pickup(super::pickups::Taken::Orb(Box::new(orb)), collector); } - self.remove_entity(p.item); + // An orb stays: the server may still hold merged ones in it. + if !orb { + self.remove_entity(p.item); + } } // ClientPacketListener.handleSetEntityMotion: knockback on this player. C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => { @@ -2149,6 +2092,8 @@ impl Game<'_> { } else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { m.limb_speed = 1.5; m.hurt_time = 10; + } else { + self.remote_dragon_hurt(p.id); } } C::HurtAnimation(p) => { @@ -2158,9 +2103,14 @@ impl Game<'_> { } } else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { m.hurt_time = 10; + } else { + self.remote_dragon_hurt(p.id); } } C::EntityEvent(p) => { + if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT { + self.remote_firework_explosion(p.id); + } // 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) @@ -2174,6 +2124,11 @@ impl Game<'_> { { horizon_client::mobs::rabbit::hop_event(m); } + if p.event == 19 + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) + { + horizon_client::mobs::aquatic::squid_cycle_event(m); + } // EntityEvent.DEATH: start the death animation. if p.event == 3 && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) @@ -2234,6 +2189,7 @@ impl Game<'_> { self.apply_remote_health(); if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() { self.screen = Screen::Dead; + self.ui.death_time = 0; self.capture(false); } } @@ -2250,6 +2206,7 @@ impl Game<'_> { // screen, whatever health was last sent. if me && self.screen != Screen::Dead && self.player.is_some() { self.screen = Screen::Dead; + self.ui.death_time = 0; self.capture(false); } } @@ -3044,8 +3001,12 @@ impl Game<'_> { /// entities whose own tick moves them on a client: dropped items, falling /// blocks and primed TNT. fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) { + self.remote_dragon_motion(id, velocity); let v = Vec3::new(velocity[0], velocity[1], velocity[2]); - if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) { + if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) { + // Squid.aiStep derives its body pitch and roll from network motion. + m.velocity = v; + } else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) { d.velocity = v; } else if let Some(f) = self.falling.iter_mut().find(|f| f.id == id as u32) { f.velocity = v; @@ -3058,12 +3019,16 @@ impl Game<'_> { .find(|p| p.id == id as u32) { p.velocity = v; + } else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) { + b.velocity = v; } else if let Some(p) = self .remote .as_mut() .and_then(|r| r.pending_items.get_mut(&id)) { p.1 = v; + } else if !self.potions.remote_thrown_motion(id as u32, v) { + self.end.remote_eye_motion(id as u32, velocity); } } fn apply_remote_health(&mut self) { @@ -3170,6 +3135,12 @@ impl Game<'_> { fn tick_remote_level(&mut self) -> Result<()> { self.ticks += 1; self.track_experience_display(); + // Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s + // for a server's client (multiplayer and the default single player), + // so the entries advance here or the slot stays shaded until the + // server says the entry ended. `onCooldownEnded` is the plain + // ItemCooldowns' no-op on a client. + self.cooldowns.tick(); // Options.broadcastOptions: a changed render distance or Chat option // is sent again. let view = client_view_distance(self.settings.render_distance); @@ -3207,8 +3178,14 @@ impl Game<'_> { self.border.tick(); self.player_border(); self.tick_remote_entities(running); - // ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells. + // ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells, + // and the moving pistons a block event's client-side moveBlocks made + // (their progress advances between the server's block updates). self.tick_block_entity_anims(); + self.with_level(|b, level, ticks| { + b.client_tick_block_entities(level, ticks); + }); + self.sync_moving_pistons(); self.tick_hand(); self.tick_particles(); self.tick_client_ambient(); @@ -3288,6 +3265,11 @@ impl Game<'_> { ); let was_flying = player.flying; self.sync_player_body(); + // LivingEntity.tickDeath: the local player counts its deathTime once + // per client tick while dead (the camera's death roll and zoom). + if self.screen == Screen::Dead { + self.ui.death_time = self.ui.death_time.saturating_add(1); + } let Some(player) = self.player.as_mut() else { return Ok(()); }; @@ -3528,6 +3510,13 @@ impl Game<'_> { if m.species() == horizon_client::mobs::Species::Creeper { horizon_client::mobs::hostile::creeper_client_tick(m); } + if matches!( + m.species(), + horizon_client::mobs::Species::Guardian + | horizon_client::mobs::Species::ElderGuardian + ) { + horizon_client::mobs::guardian::client_tick(m); + } if m.hurt_time > 0 { m.hurt_time -= 1; } @@ -3587,6 +3576,8 @@ impl Game<'_> { } else if m.alive() { m.body_rotation_tick(); } + // Squid.aiStep follows LivingEntity's interpolation and head turn. + horizon_client::mobs::aquatic::squid_client_step(m); // WalkAnimationState.update from the horizontal distance moved. let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z); let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0); @@ -3595,6 +3586,9 @@ impl Game<'_> { if m.species() == horizon_client::mobs::Species::Rabbit { horizon_client::mobs::rabbit::post_tick(m); } + if m.species() == horizon_client::mobs::Species::Bat { + horizon_client::mobs::bat::setup_animation_states(m); + } } for o in self.orbs.iter_mut().filter(|_| running) { o.previous = o.position; @@ -3616,13 +3610,25 @@ impl Game<'_> { // ticks), and only the server removes it. let (world, facts, collision) = (&self.world, self.res.facts(), self.collision); for d in self.dropped.iter_mut().filter(|_| running) { - d.tick(world, facts, collision, false); + d.tick(world, facts, collision, false, &self.res.motion().fluids); } head_lerps.retain(|_, (_, steps)| *steps > 0); for (item, ticks, at, eye, pitch, yaw) in using { self.remote_player_eating(&item, ticks, at, eye, pitch, yaw); } if running { + // The dragon: LivingEntity's interpolation, inside its aiStep. + let dragon_lerp = self + .dragon + .as_ref() + .and_then(|d| lerps.get_mut(&(d.id as i32))) + .filter(|l| l.steps > 0) + .map(|l| { + let steps = l.steps; + l.steps -= 1; + (l.target, l.rotation.map(|r| r.0), steps) + }); + self.tick_remote_dragon(dragon_lerp); // Boats, minecarts, armor stands, projectiles and the rest // interpolate like mobs. let mut step = |id: u32, at: Vec3| match lerps.get_mut(&(id as i32)) { @@ -3785,6 +3791,15 @@ impl Game<'_> { /// MultiPlayerGameMode.startDestroyBlock / continueDestroyBlock. fn remote_mining(&mut self, start: bool) { + if !self + .res + .items() + .can_destroy_block(self.held(), self.creative) + { + self.stop_remote_mining(); + self.mining = None; + return; + } let Some(r) = self.remote.as_mut() else { return; }; @@ -3924,19 +3939,25 @@ impl Game<'_> { .and_modify(|p| p.0 = sequence) .or_insert((sequence, state.0 as i32)); } - let s = self.res.facts().get(state).sounds.clone(); - let at = [ - f64::from(pos[0]) + 0.5, - f64::from(pos[1]) + 0.5, - f64::from(pos[2]) + 0.5, - ]; - self.audio.play( - &s.break_sound, - Some(at), - (s.volume + 1.0) / 2.0, - s.pitch * 0.8, - ); - self.destroy_particles(pos, state); + // Block.playerWillDestroy's destroy event (particles and the break + // sound), which BaseFireBlock skips: the server's extinguish sound + // is all a player hears. + let facts = self.res.facts().get(state); + if !facts.has_class("BaseFireBlock") { + let s = facts.sounds.clone(); + let at = [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]) + 0.5, + f64::from(pos[2]) + 0.5, + ]; + self.audio.play( + &s.break_sound, + Some(at), + (s.volume + 1.0) / 2.0, + s.pitch * 0.8, + ); + self.destroy_particles(pos, state); + } // The server's block update follows (waterlogged blocks leave water). let air = self.world.air; if self.world.set(pos[0], pos[1], pos[2], air).is_ok() { @@ -4035,15 +4056,11 @@ impl Game<'_> { world_border: false, sequence: seq, })); - let target = [ - hit.pos[0] + hit.face[0], - hit.pos[1] + hit.face[1], - hit.pos[2] + hit.face[2], - ]; - let ticks = self.ticks; + // Only a predicted placement (predict_place) marks a cell whose + // resend plays a place sound: an empty hand or a non-block item + // places nothing (BlockItem.place is where the sound comes from). if let Some(r) = self.remote.as_mut() { r.use_target = Some(hit.pos); - r.placing = Some((target, ticks)); } if reacts { self.predict_use(&changes, seq); @@ -4066,6 +4083,10 @@ impl Game<'_> { if let Some(placed) = &placed { self.predict_place(placed, seq); self.with_hand(hand, Game::consume_held); + } else if let Some(r) = self.remote.as_mut() { + // BlockItem.place plays its sound only once the block is set: the + // server's resend of the target it refused is no placement. + r.placing = None; } if reacts || placed.is_some() { self.with_hand(hand, Game::swing_arm); @@ -4606,6 +4627,7 @@ impl Game<'_> { } KeyCode::KeyE => { // The inventory opens without telling the server. + self.recipe_ui.reset_screen(); self.screen = if self.creative { Screen::Creative } else { @@ -4872,6 +4894,7 @@ impl Game<'_> { self.remote_close_local(); } fn remote_close_local(&mut self) { + self.recipe_ui.reset_screen(); if let Some(r) = self.remote.as_mut() { r.menu = None; r.menu_title = None; @@ -4907,16 +4930,20 @@ impl Game<'_> { if self.open_menu_name(p.container) != "minecraft:merchant" { return; } - let cost = |g: &Self, c: &play::NetCost| horizon_client::merchant::ItemCost { - stack: ItemStack::new(&g.item_name(c.item), c.count.max(0) as u32), + let cost = |g: &Self, c: &play::NetCost| { + g.to_stack(&Some(c.clone().into())) + .map(|stack| horizon_client::merchant::ItemCost { stack }) }; let offers: Vec<_> = p .offers .iter() .filter_map(|o| { let mut offer = horizon_client::merchant::MerchantOffer { - base_cost_a: cost(self, &o.cost_a), - cost_b: o.cost_b.as_ref().map(|b| cost(self, b)), + base_cost_a: cost(self, &o.cost_a)?, + cost_b: match &o.cost_b { + Some(b) => Some(cost(self, b)?), + None => None, + }, result: self.to_stack(&Some(o.result.clone()))?, uses: o.uses, max_uses: o.max_uses, @@ -4942,6 +4969,9 @@ impl Game<'_> { } /// ClientboundOpenScreenPacket for the menus Horizon draws. fn remote_open_screen(&mut self, p: play::OpenScreen) { + // A new RecipeBookComponent owns fresh ghost slots and an overlay, + // even when the server replaces a menu without a close packet. + self.recipe_ui.reset_screen(); let menu = self .remote .as_ref() diff --git a/crates/horizon-client/src/game/remote_chunks.rs b/crates/horizon-client/src/game/remote_chunks.rs new file mode 100644 index 000000000..47f474619 --- /dev/null +++ b/crates/horizon-client/src/game/remote_chunks.rs @@ -0,0 +1,355 @@ +//! A server's chunk columns (`ClientboundLevelChunkWithLightPacket`): the +//! sections and light of each packet become a client column on a decoder +//! thread as soon as the packet arrives, so the frame that handles it in +//! order only inserts the result (`ClientPacketListener.handleLevelChunkWithLight`). +use super::Game; +use horizon_client::{ + chunk::{BIOME_STRATEGY, Chunk, Light, MIN_Y, SECTIONS, Section, block_strategy}, + streaming::Streaming, +}; +use horizon_core::paletted::{PalettedContainer, Strategy}; +use horizon_protocol::{ + chunk::{LightData, SectionLight}, + packets::play::{self, Clientbound as C}, + registry::DimensionType, +}; +use std::sync::{ + Arc, + mpsc::{self, TryRecvError}, +}; + +/// What turning a chunk packet into a client column depends on: the +/// dimension's height and sky, and the server's biome registry mapped to +/// ours. The connection replaces it when either changes, so a column decoded +/// with an older one is decoded again. +#[derive(Default)] +pub(super) struct ChunkFormat { + min_y: i32, + sections: usize, + has_skylight: bool, + biome_map: Vec, + biome_bits: u8, +} + +impl ChunkFormat { + pub(super) fn new(dimension: &DimensionType, biome_map: Vec, biome_bits: u8) -> Self { + Self { + min_y: dimension.min_y, + sections: dimension.sections(), + has_skylight: dimension.has_skylight, + biome_map, + biome_bits, + } + } + pub(super) fn with_dimension(&self, dimension: &DimensionType) -> Self { + Self::new(dimension, self.biome_map.clone(), self.biome_bits) + } + + /// LevelChunkSection data into the client's fixed -64..320 column. + fn column(&self, p: &play::LevelChunkWithLight) -> Result { + let sections = p + .chunk + .sections(self.sections, 16, self.biome_bits) + .map_err(|e| e.to_string())?; + let mut chunk = Chunk::empty(p.x, p.z); + let offset = (self.min_y - MIN_Y).div_euclid(16); + // Biome table index per quart cell of the dimension's own sections. + let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len()); + for (i, s) in sections.iter().enumerate() { + let target = offset + i as i32; + let mut cells = [0u32; 64]; + for (j, cell) in cells.iter_mut().enumerate() { + *cell = u32::from( + self.biome_map + .get(s.biomes.get(j) as usize) + .copied() + .unwrap_or(0), + ); + } + biome_cells.push(cells); + if !(0..SECTIONS as i32).contains(&target) { + continue; + } + // A global section is as wide as the server's registry: 16 bits + // from a vanilla server, wider from a Horizon host with the same + // content mods. Read it at that width, then widen it to ours. + let ours = block_strategy(); + let strategy = if ours.wire_has_palette(s.states.bits) { + ours + } else { + Strategy::blocks(s.states.bits) + }; + let palette = s.states.palette.iter().map(|&p| p as u32).collect(); + let words = s.states.data.iter().map(|&w| w as u64).collect(); + let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words) + .map_err(|e| e.to_string())?; + if strategy.global_bits != ours.global_bits { + let mut values = [0u32; 4096]; + blocks.unpack(&mut values); + // Ids past our registry (a host with mods we lack) read as air. + let limit = 1u64 << ours.global_bits; + for v in &mut values { + if u64::from(*v) >= limit { + *v = 0; + } + } + blocks = PalettedContainer::from_values(ours, &values); + } + let mut section = Section { + blocks, + biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells), + light: Light::Uniform(0xF0), + }; + section.compact(); + chunk.sections[target as usize] = Arc::new(section); + } + // Sections outside the dimension's range take its nearest biomes + // (the column's top or bottom cell layer repeated). + if !biome_cells.is_empty() { + let last = biome_cells.len() - 1; + for si in 0..SECTIONS as i32 { + let i = si - offset; + if (0..sections.len() as i32).contains(&i) { + continue; + } + let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) }; + let mut cells = [0u32; 64]; + for (j, cell) in cells.iter_mut().enumerate() { + *cell = biome_cells[edge][layer * 16 + j % 16]; + } + Arc::make_mut(&mut chunk.sections[si as usize]).biomes = + PalettedContainer::from_values(BIOME_STRATEGY, &cells); + } + } + // ClientPacketListener.applyLightData: the column keeps the light + // the server sent; nothing is relit. + let (layers, above) = self.light(&p.light); + horizon_client::light::light_from_layers(&mut chunk, &layers, above, self.has_skylight); + Ok(chunk) + } + + /// `ClientPacketListener.readSectionList`: the sky and block layers of a + /// light packet, by section of the client's column, and the sky light + /// above the column's top section (from the light section just above + /// it, else open sky). + pub(super) fn light(&self, light: &LightData) -> (Vec, [u8; 256]) { + let mut layers: Vec = vec![(None, None); SECTIONS]; + let sky = self.has_skylight; + let mut above = [if sky { 15 } else { 0 }; 256]; + // Light section b is the dimension's section b - 1. + let first = (self.min_y - MIN_Y).div_euclid(16) - 1; + for (b, layer) in light.layers(self.sections + 2).into_iter().enumerate() { + let si = first + b as i32; + if (0..SECTIONS as i32).contains(&si) { + layers[si as usize] = layer; + } else if si == SECTIONS as i32 + && sky + && let Some(l) = &layer.0 + { + for (i, a) in above.iter_mut().enumerate() { + *a = (l[i >> 1] >> ((i & 1) * 4)) & 15; + } + } + } + (layers, above) + } +} + +type Decoded = (Box, Result); + +/// One thread that decodes chunk packets in the order they arrived; its +/// results come back in that order, one per `submit`. +pub(super) struct ChunkDecoder { + jobs: mpsc::Sender<(Arc, Box)>, + done: mpsc::Receiver, +} + +impl ChunkDecoder { + pub(super) fn new() -> Self { + let (jobs, todo) = mpsc::channel::<(Arc, Box)>(); + let (finished, done) = mpsc::channel(); + // The thread ends with the connection, when `jobs` is dropped. + let spawned = std::thread::Builder::new() + .name("Chunk decoder".into()) + .spawn(move || { + // A frame waits for its columns, as it would for the render + // thread's own work. + horizon_threads::favour_current(); + for (format, packet) in todo { + let column = match &*packet { + C::LevelChunkWithLight(p) => format.column(p), + _ => Err("not a chunk packet".into()), + }; + if finished.send((packet, column)).is_err() { + break; + } + } + }); + if let Err(e) = spawned { + log::warn!("Horizon: no chunk decoder thread ({e}); chunks decode in the frame"); + } + Self { jobs, done } + } + + /// Start decoding a chunk packet with the format it arrived under. + /// Without a decoder thread the packet comes back undecoded. + pub(super) fn submit(&self, format: Arc, packet: Box) -> Option> { + self.jobs.send((format, packet)).err().map(|e| (e.0).1) + } + + /// The oldest submitted packet and its column, unless it is still + /// decoding: a frame never waits for the decoder. + pub(super) fn next(&self) -> Next { + match self.done.try_recv() { + Ok(decoded) => Next::Ready(decoded), + Err(TryRecvError::Empty) => Next::Decoding, + // The decoder thread panicked: its columns are gone. + Err(TryRecvError::Disconnected) => Next::Lost, + } + } +} + +pub(super) enum Next { + Ready(Decoded), + Decoding, + Lost, +} + +impl Game<'_> { + /// `handleLevelChunkWithLight`: the column replaces any loaded one, and + /// its block entities load their update tags. `decoded` is the column the + /// decoder made with `format`; another format since means decode again. + pub(super) fn remote_chunk( + &mut self, + p: &play::LevelChunkWithLight, + decoded: Option<(&Arc, Result)>, + ) { + // ClientChunkCache.replaceWithPacketData: a column outside the + // storage around the view centre is ignored. + if !self.remote_chunk_in_range(p.x, p.z) { + log::warn!( + "Ignoring chunk since it's not in the view range: {}, {}", + p.x, + p.z + ); + return; + } + let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else { + return; + }; + let column = match decoded { + Some((used, column)) if Arc::ptr_eq(used, &format) => column, + _ => format.column(p), + }; + let chunk = match column { + Ok(chunk) => chunk, + Err(e) => { + log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z); + return; + } + }; + let (x, z) = (p.x, p.z); + self.streaming.insert_remote(&mut self.world, chunk); + self.world + .block_entities + .retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z)); + for info in &p.chunk.block_entities { + let pos = ( + x * 16 + i32::from(info.packed_xz as u8 >> 4), + i32::from(info.y), + z * 16 + i32::from(info.packed_xz as u8 & 15), + ); + let state = self.world.block(pos.0, pos.1, pos.2); + self.sync_block_entity(pos, state); + // LevelChunk.replaceWithPacketData: the block entity loads its update tag. + self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag); + } + } + + /// `handleLightUpdatePacket`: the sections it names take its light. + pub(super) fn remote_light_update(&mut self, p: &play::LightUpdate) { + let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else { + return; + }; + if !self.world.chunks.contains_key(&(p.x, p.z)) { + return; + } + let (layers, _) = format.light(&p.light); + let Some(chunk) = self.world.chunks.get_mut(&(p.x, p.z)) else { + return; + }; + let changed = horizon_client::light::update_light_layers( + Arc::make_mut(chunk), + &layers, + format.has_skylight, + ); + Streaming::light_updated(&mut self.world, p.x, p.z, &changed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_protocol::chunk::{ChunkData, Paletted, Section as Wire, Strategy as Wiring}; + + /// A column of `state` with biome 1 everywhere, unlit. + fn packet(x: i32, state: i32) -> Box { + let section = Wire { + non_empty: 4096, + fluids: 0, + states: Paletted::from_values(&[state; 4096], Wiring::Blocks, 16), + biomes: Paletted::from_values(&[1; 64], Wiring::Biomes, 2), + }; + let none = LightData { + sky_mask: Vec::new(), + block_mask: Vec::new(), + empty_sky_mask: Vec::new(), + empty_block_mask: Vec::new(), + sky: Vec::new(), + block: Vec::new(), + }; + Box::new(C::LevelChunkWithLight(play::LevelChunkWithLight { + x, + z: -3, + chunk: ChunkData { + heightmaps: Vec::new(), + buffer: ChunkData::from_sections(&vec![section; 24]), + block_entities: Vec::new(), + }, + light: none, + })) + } + + /// Columns come back one per packet, in the order the packets went in, + /// as the frame would have decoded them itself. + #[test] + fn the_decoder_returns_columns_in_order() { + let overworld = DimensionType::vanilla("minecraft:overworld"); + let format = Arc::new(ChunkFormat::new(&overworld, vec![0, 7, 9], 2)); + let decoder = ChunkDecoder::new(); + for x in 0..20 { + assert!(decoder.submit(format.clone(), packet(x, 1 + x)).is_none()); + } + for x in 0..20 { + let (packet, column) = loop { + match decoder.next() { + Next::Ready(decoded) => break decoded, + Next::Decoding => std::thread::yield_now(), + Next::Lost => panic!("the decoder stopped"), + } + }; + let C::LevelChunkWithLight(p) = *packet else { + panic!("not a chunk packet"); + }; + let (column, direct) = (column.unwrap(), format.column(&p).unwrap()); + assert_eq!((column.x, column.z), (x, -3)); + for (cx, y, cz) in [(0, -64, 0), (5, 70, 9), (15, 319, 15)] { + assert_eq!(column.get(cx, y, cz), 1 + x as u32); + assert_eq!(column.get(cx, y, cz), direct.get(cx, y, cz)); + // Wire biome 1 is our biome 7. + assert_eq!(column.biome(cx, y, cz), 7); + } + } + assert!(matches!(decoder.next(), Next::Decoding)); + } +} diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index fbfc5a4fb..c6702dfb6 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -4,14 +4,14 @@ //! own simulation, which a client of a server doesn't run. use super::Game; use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES}; -use horizon_client::mobs::{Mob, Species, tame}; +use horizon_client::mobs::{Mob, Species, Who, bat, tame}; use horizon_protocol::entity_data::Value; use horizon_protocol::registry::Builtin; /// The values `apply` takes from one SetEntityData packet, by vanilla's entry /// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses). /// `builtin` names the ids of built-in registries. -fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) { +fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) { let species = m.species(); let baby = super::lan::baby_index(&m.kind.id); for (index, _, value) in values { @@ -46,6 +46,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) { _ => {} } match (species, *index, value) { + (Species::Guardian | Species::ElderGuardian, 16, Value::Bool(moving)) => { + m.extra.flag2 = *moving; + } + (Species::Guardian | Species::ElderGuardian, 17, Value::Int(target)) => { + // Guardian.onSyncedDataUpdated resets the visual charge clock. + m.extra.counter2 = 0; + m.extra.flag = *target != 0; + m.ai.target = (*target != 0).then_some(if *target == local_entity { + Who::Player + } else { + Who::Mob(*target as u32) + }); + } + // Bat.DATA_ID_FLAGS: resting in bit 0. + (Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0), // Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared. (Species::Sheep, 18, Value::Byte(wool)) => { m.extra.color = (*wool & 15) as u8; @@ -114,7 +129,7 @@ impl Game<'_> { return; }; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) { - apply(m, values, &r.builtin); + apply(m, values, &r.builtin, r.entity_id); } } } @@ -126,6 +141,10 @@ pub(super) fn client_defaults(m: &mut Mob) { if matches!(m.species(), Species::Wolf | Species::Cat) { m.extra.color = tame::RED; } + if m.species() == Species::Bat { + // Bat's constructor sets resting only on the server; the data default is 0. + bat::set_resting(m, false); + } } #[cfg(test)] @@ -147,7 +166,7 @@ mod tests { } fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) { - super::apply(m, values, &builtin()); + super::apply(m, values, &builtin(), 99); } fn mob(kind: &str) -> Option { @@ -158,6 +177,49 @@ mod tests { Some(m) } + #[test] + fn guardian_charge_follows_target_updates_and_caps_at_its_duration() { + use horizon_client::mobs::guardian::{beam, client_tick}; + for (kind, duration) in [("guardian", 80), ("elder_guardian", 60)] { + let Some(mut m) = mob(&format!("minecraft:{kind}")) else { + return; + }; + assert!(beam(&m, 0.0).is_none()); + apply( + &mut m, + &[ + (16, s::BOOLEAN, Value::Bool(true)), + (17, s::INT, Value::Int(99)), + ], + ); + assert!(m.extra.flag2); + assert_eq!(m.ai.target, Some(Who::Player)); + assert_eq!(beam(&m, 0.0), Some((0.0, 0.0))); + for _ in 0..duration + 10 { + client_tick(&mut m); + } + assert_eq!(beam(&m, 0.0), Some((duration as f32, 1.0))); + // Another player or a mob retains its network entity id. + apply(&mut m, &[(17, s::INT, Value::Int(42))]); + assert_eq!(m.ai.target, Some(Who::Mob(42))); + assert_eq!(m.extra.counter2, 0); + client_tick(&mut m); + assert_eq!(m.extra.counter2, 1); + apply( + &mut m, + &[ + (16, s::BOOLEAN, Value::Bool(false)), + (17, s::INT, Value::Int(0)), + ], + ); + client_tick(&mut m); + assert!(!m.extra.flag2); + assert_eq!(m.ai.target, None); + assert_eq!(m.extra.counter2, 0); + assert!(beam(&m, 0.0).is_none()); + } + } + #[test] fn a_sheep_takes_its_wool_and_a_calf_its_size() { let Some(mut m) = mob("minecraft:sheep") else { @@ -174,6 +236,69 @@ mod tests { assert!(!calf.is_baby()); } + #[test] + fn bat_flags_select_continuous_client_clocks_without_changing_motion() { + use horizon_client::mobs::{MobTypes, anim, keyframes::Animation}; + use horizon_core::geometry::Vec3; + use serde_json::json; + use std::{collections::HashMap, fs, sync::Arc}; + + // Original minimal resources keep this regression runnable without vanilla data. + let root = + std::env::temp_dir().join(format!("horizon-bat-animation-{}", std::process::id())); + fs::create_dir(&root).unwrap(); + fs::write(root.join("entity-types.json"), r#"{"minecraft:bat": {}}"#).unwrap(); + fs::write( + root.join("entity-models.json"), + r#"{"minecraft:bat#main": {"children": {"right_wing": {}, "head": {}}}}"#, + ) + .unwrap(); + let mut types = MobTypes::load(&root).unwrap(); + fs::remove_dir_all(root).unwrap(); + let position = Vec3::new(1.0, 2.25, 3.0); + let velocity = Vec3::new(0.2, 0.3, 0.4); + let mut m = Mob::new( + 1, + Arc::new(types.types.remove("minecraft:bat").unwrap()), + position, + 0.0, + ); + assert!(bat::resting(&m)); + client_defaults(&mut m); + assert!(!bat::resting(&m)); + m.velocity = velocity; + m.age = 1; + bat::setup_animation_states(&mut m); + m.age = 4; + apply(&mut m, &[(16, s::BYTE, Value::Byte(2))]); + bat::setup_animation_states(&mut m); + assert_eq!(m.anim.states[0].started, Some(1.0)); + + let curve = Animation::parse(&json!({"length": 1, "looping": true, + "bones": {"right_wing": [{"target": "rotation", "keyframes": + [[0,[0,0,0],[0,0,0],"linear"],[1,[0,1,0],[0,1,0],"linear"]]}]}})); + let animations = HashMap::from([("BatAnimation.BAT_FLYING".into(), curve)]); + let wing = |partial| { + let mut poses = anim::Poses::new(&m.kind.model); + anim::setup(&m, &anim::state(&m, partial), &mut poses, &animations); + poses.get("right_wing")[4] + }; + assert!(wing(0.5) > wing(0.0)); + + m.age = 5; + apply(&mut m, &[(16, s::BYTE, Value::Byte(3))]); + bat::setup_animation_states(&mut m); + assert!(bat::resting(&m)); + assert_eq!(m.anim.states[0].started, None); + assert_eq!(m.anim.states[1].started, Some(5.0)); + m.age = 7; + apply(&mut m, &[(16, s::BYTE, Value::Byte(0))]); + bat::setup_animation_states(&mut m); + assert_eq!(m.anim.states[0].started, Some(7.0)); + assert_eq!(m.anim.states[1].started, None); + assert_eq!((m.position, m.velocity), (position, velocity)); + } + #[test] fn a_wolf_takes_its_owner_flags_and_collar() { let Some(mut m) = mob("minecraft:wolf") else { diff --git a/crates/horizon-client/src/game/remote_objects.rs b/crates/horizon-client/src/game/remote_objects.rs index 251d277f3..05b1a2b9e 100644 --- a/crates/horizon-client/src/game/remote_objects.rs +++ b/crates/horizon-client/src/game/remote_objects.rs @@ -3,7 +3,7 @@ //! `handleSetEquipment`): the server simulates them, the client places them //! where it says and interpolates their moves like a mob's. use super::Game; -use horizon_client::entities::{ArmorStand, ItemFrame, Painting}; +use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting}; use horizon_core::geometry::Vec3; use horizon_protocol::entity_data::Value; use std::sync::Arc; @@ -57,6 +57,16 @@ impl Game<'_> { invisible: false, check_interval: 0, }); + } else if kind == "minecraft:cushion" { + // BlockAttachedEntity.pos: `BlockPos.containing` of the spawn. + self.cushions.push(Cushion { + id, + position: at, + pos: block, + yaw: super::cushions::snap_yaw(yaw), + color: 0, + check_interval: 0, + }); } else if kind == "minecraft:lightning_bolt" { self.spawn_remote_bolt(id, position); } else if kind == "minecraft:painting" { @@ -152,6 +162,11 @@ impl Game<'_> { } _ => {} } + } else if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) { + // Cushion.DATA_COLOR (DyeColor, a byte-backed id). + if let (8, Value::Id(color)) = (index, value) { + c.color = (*color).clamp(0, 15) as u8; + } } else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) { // VehicleEntity's hurt, direction and damage; the paddles. match (index, value) { @@ -267,6 +282,12 @@ impl Game<'_> { out.push(json!({"id": a.id, "kind": "minecraft:armor_stand", "position": at(a.position), "yaw": a.yaw, "small": a.small, "arms": a.show_arms, "no_base_plate": a.no_base_plate, "equipment": items})); } + for c in &self.cushions { + out.push( + json!({"id": c.id, "kind": "minecraft:cushion", "position": at(c.position), + "yaw": c.yaw, "color": c.color_name()}), + ); + } for f in &self.item_frames { out.push( json!({"id": f.id, "kind": f.kind(), "position": f.pos, "facing": f.facing, @@ -286,6 +307,7 @@ impl Game<'_> { self.carts.clear(); self.boats.clear(); self.armor_stands.clear(); + self.cushions.clear(); self.item_frames.clear(); self.paintings.clear(); } @@ -295,6 +317,7 @@ impl Game<'_> { self.carts.retain(|c| c.id != id); self.boats.retain(|b| b.id != id); self.armor_stands.retain(|a| a.id != id); + self.cushions.retain(|c| c.id != id); self.item_frames.retain(|f| f.id != id); self.paintings.retain(|p| p.id != id); self.bolts.retain(|b| b.id != id); diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 2ba4510ea..897999c6b 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -3,11 +3,26 @@ //! and `handleSetEntityData`): the server simulates them, the client draws //! them where it says and interpolates their moves. use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector}; -use horizon_client::projectiles::{Projectile, ProjectileKind}; -use horizon_core::{geometry::Vec3, position::BlockPos}; +use horizon_client::particles::{ParticleOptions, ParticleType}; +use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld}; +use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource}; use horizon_protocol::entity_data::Value; use horizon_world::registry::BlockStateId; +/// An `AreaEffectCloud` a server tracks: what its client tick reads. +pub(super) struct RemoteCloud { + id: u32, + at: [f64; 3], + radius: f32, + waiting: bool, + particle: ParticleOptions, +} + +/// `AreaEffectCloud.DEFAULT_PARTICLE`: a white entity effect. +fn default_cloud_particle() -> ParticleOptions { + ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4]) +} + /// The projectile a type id names, as Horizon models it. fn projectile_kind(kind: &str) -> Option { Some(match kind { @@ -37,6 +52,32 @@ fn projectile_kind(kind: &str) -> Option { }) } +/// `AbstractArrow.tick` on a client: water drag before the move, air drag +/// after it, then gravity; a block it reaches holds it (`inGround`). +fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) { + p.previous = p.position; + p.age += 1; + if p.stuck { + return; + } + let water = w.in_water(p.position); + if water { + p.velocity = p.velocity.scale(0.6); + } + let to = p.position.add_vector(p.velocity); + if let Some((at, _, _)) = w.clip(p.position, to) { + p.position = at; + p.velocity = Vec3::ZERO; + p.stuck = true; + return; + } + p.position = to; + if !water { + p.velocity = p.velocity.scale(0.99); + } + p.velocity.y -= 0.05; +} + impl Game<'_> { /// `handleAddEntity` for these types; false when `kind` isn't one. pub(super) fn spawn_remote_projectile( @@ -92,6 +133,16 @@ impl Game<'_> { | "minecraft:experience_bottle" => { self.potions.add_remote_thrown(id, kind, at, velocity); } + "minecraft:dragon_fireball" => self + .dragon_fireballs + .push(super::dragon::DragonFireball::remote(id, at, velocity)), + "minecraft:area_effect_cloud" => self.potions.clouds.push(RemoteCloud { + id, + at: position, + radius: 3.0, + waiting: false, + particle: default_cloud_particle(), + }), "minecraft:eye_of_ender" => { self.end .add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z]) @@ -125,6 +176,31 @@ impl Game<'_> { pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) { let id = id as u32; self.remote_hook_data(id, values); + if let Some(at) = self + .potions + .clouds + .iter() + .find(|c| c.id == id) + .map(|c| c.at) + { + // AreaEffectCloud.DATA_RADIUS, DATA_WAITING and DATA_PARTICLE. + for (index, _, value) in values { + let particle = match (index, value) { + (10, Value::Particle(p)) => self.received_particle(p, at), + _ => None, + }; + let Some(c) = self.potions.clouds.iter_mut().find(|c| c.id == id) else { + return; + }; + match (index, value) { + (8, Value::Float(r)) => c.radius = *r, + (9, Value::Bool(w)) => c.waiting = *w, + (10, _) => c.particle = particle.unwrap_or_else(default_cloud_particle), + _ => {} + } + } + return; + } if self.rockets.iter().any(|r| r.id == id) { for (index, _, value) in values { match (index, value) { @@ -246,29 +322,34 @@ impl Game<'_> { } } self.falling = falling; - for p in &mut self.creatures.projectiles { - p.previous = p.position; - p.age += 1; - if matches!(p.kind, ProjectileKind::LargeFireball { .. }) { - // AbstractHurtingProjectile.tick advances on the client between - // the tracker updates (ten ticks apart for a ghast's fireball). - let at = p.position; - let water = self - .res - .facts() - .get(self.world.block( - at.x.floor() as i32, - at.y.floor() as i32, - at.z.floor() as i32, - )) - .fluid - == horizon_client::blocks::Fluid::Water; - p.apply_hurting_inertia(water); - p.position = p.position.add_vector(p.velocity); - } else { - p.position = step(p.id, p.position); + // Projectiles tick on the client too and fly on between the tracker's + // updates (10 to 20 ticks apart); a block stops them there, and only + // the server decides what they hit. + let mut projectiles = std::mem::take(&mut self.creatures.projectiles); + { + let w = self.mob_world_base(&[]); + let world = super::creatures::ProjWorld { + w: &w, + targets: Vec::new(), + }; + for p in &mut projectiles { + match p.kind { + ProjectileKind::Arrow { .. } => remote_arrow_tick(p, &world), + // ShulkerBullet.tick: only the server steers it. + ProjectileKind::ShulkerBullet { .. } => { + p.previous = p.position; + p.age += 1; + p.position = p.position.add_vector(p.velocity); + } + _ => { + if !matches!(p.tick(&world).1, Hit::None) { + p.velocity = Vec3::ZERO; + } + } + } } } + self.creatures.projectiles = projectiles; for t in &mut self.tridents { let id = t.id; t.remote_step(|at| step(id, at)); @@ -302,12 +383,78 @@ impl Game<'_> { c.time += 1; c.position = step(c.id, c.position); } - self.potions.step_remote_thrown(&mut step); + self.step_remote_thrown(); self.step_remote_hooks(&mut step); - self.end.step_remote_eyes(|id, at| { - let at = step(id, Vec3::new(at[0], at[1], at[2])); - [at.x, at.y, at.z] - }); + self.end.step_remote_eyes(); + self.tick_remote_clouds(); + // The dragon's fireballs fly on between the tracker's updates, with + // their dragon_breath trail (`AbstractHurtingProjectile.getTrailParticle`). + let trail = ParticleOptions::simple(ParticleType::DragonBreath); + for b in &mut self.dragon_fireballs { + let at = b.position; + let water = self + .res + .facts() + .get(self.world.block( + at.x.floor() as i32, + at.y.floor() as i32, + at.z.floor() as i32, + )) + .fluid + == horizon_client::blocks::Fluid::Water; + b.remote_step(water); + let p = b.position; + self.particles.add(&trail, [p.x, p.y + 0.5, p.z], [0.0; 3]); + } + } + + /// `AreaEffectCloud.clientTick`: while waiting, now and then two + /// particles near the middle; then about one per square block of the + /// disc, drifting (an entity effect stays still). + fn tick_remote_clouds(&mut self) { + for c in &self.potions.clouds { + let r = self.particles.random(); + if c.waiting && r.next_boolean() { + continue; + } + let (count, spread) = if c.waiting { + (2, 0.2) + } else { + ( + (std::f32::consts::PI * c.radius * c.radius).ceil() as i32, + c.radius, + ) + }; + for _ in 0..count { + let r = self.particles.random(); + let angle = r.next_float() * std::f32::consts::TAU; + let distance = r.next_float().sqrt() * spread; + let at = [ + c.at[0] + f64::from(angle.cos() * distance), + c.at[1], + c.at[2] + f64::from(angle.sin() * distance), + ]; + let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect { + let default = c.waiting && r.next_boolean(); + let options = if default { + default_cloud_particle() + } else { + c.particle.clone() + }; + (options, [0.0; 3]) + } else if c.waiting { + (c.particle.clone(), [0.0; 3]) + } else { + let v = [ + (0.5 - r.next_double()) * 0.15, + f64::from(0.01f32), + (0.5 - r.next_double()) * 0.15, + ]; + (c.particle.clone(), v) + }; + self.particles.add(&options, at, velocity); + } + } } pub(super) fn remove_remote_projectile(&mut self, id: u32) { @@ -318,6 +465,8 @@ impl Game<'_> { self.rockets.retain(|r| r.id != id); self.crystals.retain(|c| c.id != id); self.potions.remove_remote_thrown(id); + self.potions.clouds.retain(|c| c.id != id); + self.dragon_fireballs.retain(|b| b.id != id); self.end.remove_remote_eye(id); self.remove_remote_hook(id); } @@ -330,7 +479,11 @@ impl Game<'_> { self.tridents.clear(); self.rockets.clear(); self.crystals.clear(); + // The dragon a server sent goes with its crystals. + self.dragon = None; self.potions.clear_thrown(); + self.potions.clouds.clear(); + self.dragon_fireballs.clear(); self.end.clear_remote_eyes(); self.clear_remote_hooks(); } @@ -353,7 +506,8 @@ impl Game<'_> { out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)})); } for p in &self.creatures.projectiles { - out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)})); + out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity), + "player_owned": p.owner == Some(horizon_client::mobs::Who::Player)})); } for (kind, p, _) in self.potions.thrown_entities() { out.push(json!({"kind": kind, "position": at(p)})); diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index 620aff97c..877ed66e6 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -56,6 +56,11 @@ impl Game<'_> { let p = a.position; hits.push((a.id as i32, d, [p.x, p.y, p.z])); } + if let Some((i, d, _)) = self.cushion_hit() { + let c = &self.cushions[i]; + let p = c.position; + hits.push((c.id as i32, d, [p.x, p.y, p.z])); + } if let Some((i, d)) = self.boat_hit() { let p = self.boats[i].position; hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z])); @@ -79,6 +84,8 @@ impl Game<'_> { hits.push((c.id as i32, d, [p.x, p.y, p.z])); } } + // EnderDragonPart.isPickable: the server's dragon hurts by part. + hits.extend(self.remote_dragon_part_hit(eye, dir, reach)); } let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?; let point = at(distance); @@ -138,7 +145,10 @@ impl Game<'_> { self.position_rider(); return true; } - let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) { + let seat = if let Some(c) = self.cushions.iter().find(|c| c.id == id) { + // The seat does not move; the rider sits on the cushion. + Some(c.seat()) + } else if let Some(i) = self.boats.iter().position(|b| b.id == id) { // AbstractBoat.tick on the controlling client: steer and move. let mut b = self.boats[i].clone(); b.input = [keys.left, keys.right, keys.forward, keys.backward]; diff --git a/crates/horizon-client/src/game/saving.rs b/crates/horizon-client/src/game/saving.rs index 378ffe9ce..1c6632562 100644 --- a/crates/horizon-client/src/game/saving.rs +++ b/crates/horizon-client/src/game/saving.rs @@ -179,6 +179,7 @@ impl Game<'_> { if keys.is_empty() { self.prune_entity_memory(); self.save_raids(); + self.save.save_forced_chunks(&self.streaming.forced)?; self.save_game_metadata()?; save.stage = Stage::Writes; } diff --git a/crates/horizon-client/src/game/server_travel.rs b/crates/horizon-client/src/game/server_travel.rs index 158b2301e..bcaf37a76 100644 --- a/crates/horizon-client/src/game/server_travel.rs +++ b/crates/horizon-client/src/game/server_travel.rs @@ -13,6 +13,7 @@ use super::respawn_anchor::RespawnConfig; use super::{Dim, Game, lan::LanHost}; use horizon_client::{lan::hub::TickCtx, text::Text}; use horizon_core::{geometry::Vec3, position::BlockPos}; +use horizon_protocol::packets::play::{self, Clientbound as C}; /// A trip under way: the player is in the destination's level, whose /// chunks are loading around `target`. @@ -103,8 +104,35 @@ impl Game<'_> { if let Some(pos) = self.nether_portal_inside(at) { self.portals.player.set_inside(pos, PLAYER_DELAY); } + // `ServerPlayer.showEndCredits`: the player stays out of the world while the + // poem rolls, then PERFORM_RESPAWN takes it to its respawn point. + let play = host.play.entry(conn).or_default(); + if play.won_game { + if std::mem::take(&mut play.respawn_requested) { + play.won_game = false; + self.start_server_trip(host, conn, Dim::Overworld, None); + self.portals.player.cooldown = 10; + } + return; + } let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT); if end_portal { + // EndPortalBlock.entityInside: the first exit from the End shows the credits. + if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) { + if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) { + g.seen_credits = true; + } + host.play.entry(conn).or_default().won_game = true; + // ClientboundGameEventPacket.WIN_GAME. + host.hub.send_to( + conn, + &C::GameEvent(play::GameEvent { + event: 4, + param: 0.0, + }), + ); + return; + } if self.portals.player.cooldown == 0 { let target = if self.dim == Dim::End { Dim::Overworld diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index 2f50ad9ab..2d010f1f5 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -6,9 +6,9 @@ use horizon_client::{ containers::BlockEntity, hud::{HudBatch, rgba}, signs::wood_type, + ui::EditBox, }; use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S}; -use std::time::Instant; use winit::{ event::{ElementState, KeyEvent}, keyboard::{KeyCode, PhysicalKey}, @@ -18,7 +18,16 @@ pub(super) struct SignEditor { pub pos: (i32, i32, i32), pub front: bool, pub line: usize, - pub opened: Instant, + /// The line being edited (`AbstractSignEditScreen`'s text field). + edit: EditBox, +} + +fn sign_box() -> EditBox { + let mut edit = EditBox::new(0.0, 0.0, 90.0, 9.0); + edit.bordered = false; + edit.max_length = 384; + edit.focused = true; + edit } impl Game<'_> { @@ -28,8 +37,9 @@ impl Game<'_> { pos, front, line: 0, - opened: Instant::now(), + edit: sign_box(), }); + self.sign_load(); self.keys.clear(); self.right = false; self.capture(false); @@ -140,97 +150,154 @@ impl Game<'_> { /// A key and the text it typed on the sign being edited (the keyboard, /// or a controller's on-screen keyboard). pub(super) fn sign_input(&mut self, key: Option, typed: Option<&str>) { - let Some(edit) = self.sign_edit.as_mut() else { + if self.sign_edit.is_none() { return; - }; - let front = edit.front; - let id = sign_line_id(front, edit.line); + } match key { Some(KeyCode::Escape) => self.close_sign_editor(), - Some(KeyCode::ArrowUp) => edit.line = (edit.line + 3) & 3, + Some(KeyCode::ArrowUp) => self.sign_move_line(3), Some(KeyCode::ArrowDown | KeyCode::Enter | KeyCode::NumpadEnter) => { - edit.line = (edit.line + 1) & 3 + self.sign_move_line(1) } - Some(KeyCode::Backspace) => { - let (line, key) = (edit.line, edit.pos); - if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) { - let text = if front { - &mut sign.front - } else { - &mut sign.back - }; - let value = &mut text.lines[line]; - if !self.text_selection.take(id, value) { - value.pop(); - } - } + Some( + key @ (KeyCode::ArrowLeft + | KeyCode::ArrowRight + | KeyCode::Home + | KeyCode::End + | KeyCode::Backspace + | KeyCode::Delete), + ) => { + let (ctrl, shift) = (self.has_control_down(), self.has_shift_down()); + self.sign_change(|edit| { + edit.key(key, ctrl, shift); + }); } _ => { let Some(typed) = typed else { return; }; - let line = edit.line; - let atlas = &self.gui.atlas; - let width = |s: &str| HudBatch::text_width(atlas, s); - let Some(key) = self.sign_edit.as_ref().map(|e| e.pos) else { - return; - }; - if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) { - let max = sign.max_line_width(); - let text = if front { - &mut sign.front - } else { - &mut sign.back - }; - self.text_selection.take(id, &mut text.lines[line]); - for ch in typed.chars().filter(|c| !c.is_control()) { - let mut next = text.lines[line].clone(); - next.push(ch); - // TextFieldHelper filter: font.width(s) <= maxTextLineWidth. - if width(&next) <= max { - text.lines[line] = next; - } - } - } + self.sign_change(|edit| edit.insert_text(typed)); } } } - /// `TextFieldHelper.keyPressed`'s shortcuts on the edited line: a paste - /// (formatting and carriage returns removed) is taken whole or not at - /// all, as the line must stay within the sign's width. - fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) { + /// Up or down a line: the box writes the line it leaves and loads the next. + fn sign_move_line(&mut self, by: usize) { + self.sign_write(); + if let Some(edit) = self.sign_edit.as_mut() { + edit.line = (edit.line + by) & 3; + } + self.sign_load(); + } + /// The edited line's text. + fn sign_current(&self) -> Option { + let edit = self.sign_edit.as_ref()?; + let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else { + return None; + }; + let side = if edit.front { &sign.front } else { &sign.back }; + Some(side.lines[edit.line].clone()) + } + /// `EditBox.setValue` from the line now being edited. + fn sign_load(&mut self) { + let Some(text) = self.sign_current() else { + return; + }; + if let Some(edit) = self.sign_edit.as_mut() { + edit.edit.set_value(&text); + } + } + /// The box's text onto the sign, when it still fits the line. + fn sign_write(&mut self) { let Some(edit) = self.sign_edit.as_ref() else { return; }; - let (key, front, line) = (edit.pos, edit.front, edit.line); - let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else { + let (pos, front, line, value) = (edit.pos, edit.front, edit.line, edit.edit.value.clone()); + if !self.sign_fits(&value) { + return; + } + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else { return; }; - let max = sign.max_line_width(); - let text = if front { + let side = if front { &mut sign.front } else { &mut sign.back }; - let atlas = &self.gui.atlas; - self.text_selection.apply( - sign_line_id(front, line), - &mut text.lines[line], - shortcut, - &mut self.clipboard, - |value, pasted| { - let next = format!( - "{value}{}", - horizon_client::clipboard::helper_clipboard_contents(pasted) - ); - HudBatch::text_width(atlas, &next) <= max && { - *value = next; - true - } - }, - ); + side.lines[line] = value; } - /// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20). + /// TextFieldHelper: `font.width(s) <= maxTextLineWidth`. + fn sign_fits(&self, value: &str) -> bool { + let Some(edit) = self.sign_edit.as_ref() else { + return false; + }; + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) else { + return false; + }; + HudBatch::text_width(&self.gui.atlas, value) <= sign.max_line_width() + } + /// Edit the line, then keep the result only when it still fits. + fn sign_change(&mut self, f: impl FnOnce(&mut EditBox)) { + let before = self + .sign_edit + .as_ref() + .map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight)); + if let Some(edit) = self.sign_edit.as_mut() { + f(&mut edit.edit); + let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value); + if clean != edit.edit.value { + let n = horizon_client::ui::char_len(&clean); + edit.edit.value = clean; + edit.edit.cursor = edit.edit.cursor.min(n); + edit.edit.highlight = edit.edit.highlight.min(n); + } + } + self.sign_keep(before); + } + /// Write the box when the line still fits; otherwise put the old text back. + fn sign_keep(&mut self, before: Option<(String, usize, usize)>) { + let value = self + .sign_edit + .as_ref() + .map(|e| e.edit.value.clone()) + .unwrap_or_default(); + if self.sign_fits(&value) { + self.sign_write(); + } else if let (Some(edit), Some((value, cursor, highlight))) = + (self.sign_edit.as_mut(), before) + { + edit.edit.value = value; + edit.edit.cursor = cursor; + edit.edit.highlight = highlight; + } + } + /// `TextFieldHelper.keyPressed`'s shortcuts on the edited line. Paste + /// reads `getClipboardContents` before `insertText`. A paste that would + /// pass the sign's width is refused whole. + fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) { + if shortcut == horizon_client::clipboard::EditShortcut::Paste { + let raw = horizon_client::clipboard::Clipboard::get(&mut self.clipboard); + let text = horizon_client::clipboard::helper_clipboard_contents(&raw); + self.sign_change(|edit| edit.insert_text(&text)); + return; + } + let before = self + .sign_edit + .as_ref() + .map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight)); + if let Some(edit) = self.sign_edit.as_mut() { + edit.edit.shortcut(shortcut, &mut self.clipboard); + let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value); + if clean != edit.edit.value { + let n = horizon_client::ui::char_len(&clean); + edit.edit.value = clean; + edit.edit.cursor = edit.edit.cursor.min(n); + edit.edit.highlight = edit.edit.highlight.min(n); + } + } + self.sign_keep(before); + } + /// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20), + /// or a click on a line (`EditBox.onClick`). pub(super) fn sign_edit_click(&mut self, size: [u32; 2]) { let b = HudBatch::new(size); let (x, y) = (b.width / 2.0 - 100.0, b.height / 4.0 + 144.0); @@ -238,7 +305,56 @@ impl Game<'_> { if mx >= x && mx < x + 200.0 && my >= y && my < y + 20.0 { self.audio.play_button_click(); self.close_sign_editor(); + return; } + let Some((cx, top, line_height, max_width, lines)) = self.sign_lines(b.width) else { + return; + }; + let shift = self.has_shift_down(); + for i in 0..lines.len() { + let ly = top + i as f32 * line_height; + let left = cx - max_width / 2.0; + if mx < left || mx >= left + max_width || my < ly || my >= ly + line_height { + continue; + } + let current = self.sign_edit.as_ref().map(|e| e.line); + if current != Some(i) { + self.sign_move_line((i + 4 - current.unwrap_or(0)) & 3); + } + let value = self + .sign_edit + .as_ref() + .map(|e| e.edit.value.clone()) + .unwrap_or_default(); + let lw = HudBatch::text_width(&self.gui.atlas, &value); + let text_x = cx - (lw as i32 / 2) as f32; + if let Some(edit) = self.sign_edit.as_mut() { + edit.edit.x = text_x; + edit.edit.y = ly; + edit.edit.width = max_width; + edit.edit.click(&self.gui.atlas, mx, shift, false); + } + self.sign_write(); + return; + } + } + /// Centre x, the first line's y, the line height, the width limit and the four lines. + fn sign_lines(&self, width: f32) -> Option<(f32, f32, f32, f32, Vec)> { + let edit = self.sign_edit.as_ref()?; + let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else { + return None; + }; + let text = if edit.front { &sign.front } else { &sign.back }; + let line_height = sign.line_height(); + let y = if sign.hanging { 125.0 } else { 90.0 }; + let top = y - (4.0 * line_height / 2.0).floor(); + Some(( + width / 2.0, + top, + line_height, + sign.max_line_width(), + text.lines.to_vec(), + )) } pub(super) fn draw_sign_editor(&self, batch: &mut HudBatch) { let Some(edit) = &self.sign_edit else { return }; @@ -304,23 +420,48 @@ impl Game<'_> { let color = rgba((color >> 16) as u8, (color >> 8) as u8, color as u8, 255); let line_height = sign.line_height(); let midpoint = (4.0 * line_height / 2.0).floor(); - let blink = (edit.opened.elapsed().as_millis() / 300) % 2 == 0; for (i, line) in text.lines.iter().enumerate() { - let lw = HudBatch::text_width(atlas, line); + let shown = if i == edit.line { + edit.edit.value.as_str() + } else { + line.as_str() + }; + let lw = HudBatch::text_width(atlas, shown); let x = cx - (lw as i32 / 2) as f32; let ly = y + i as f32 * line_height - midpoint; - batch.text(atlas, line, x, ly, color, false); - if i == edit.line - && self - .text_selection - .covers(sign_line_id(edit.front, i), line) - { - // AbstractSignEditScreen: the selection from its start to its end. - batch.text_highlight(atlas, (x, ly, lw, line_height), true); + batch.text(atlas, shown, x, ly, color, false); + if i != edit.line { + continue; } - if i == edit.line && blink { - // TextCursorUtils.extractAppendCursor: an underscore after the text. - batch.text(atlas, "_", x + lw, ly, color, false); + let value = &edit.edit.value; + if edit.edit.cursor != edit.edit.highlight { + let (a, b) = ( + edit.edit.cursor.min(edit.edit.highlight), + edit.edit.cursor.max(edit.edit.highlight), + ); + let pre: String = value.chars().take(a).collect(); + let sel: String = value.chars().skip(a).take(b - a).collect(); + batch.text_highlight( + atlas, + ( + x + HudBatch::text_width(atlas, &pre), + ly, + HudBatch::text_width(atlas, &sel), + line_height, + ), + true, + ); + } + if edit.edit.cursor_visible() { + let pre: String = value.chars().take(edit.edit.cursor).collect(); + batch.fill( + atlas, + x + HudBatch::text_width(atlas, &pre), + ly, + 1.0, + line_height, + color, + ); } } let (bx, by) = (w / 2.0 - 100.0, h / 4.0 + 144.0); @@ -344,8 +485,3 @@ impl Game<'_> { ); } } - -/// The sign line being edited, for Select All. -fn sign_line_id(front: bool, line: usize) -> horizon_client::clipboard::FieldId { - (if front { "sign.front" } else { "sign.back" }, line) -} diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index b7283c57b..b1953596b 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -171,8 +171,8 @@ pub(super) struct Spawn { pub(super) struct Spill { pub(super) spawns: Vec, /// Experience orbs (`ExperienceOrb.award`): where and how much, each - /// having drawn its merge id from the level random. - pub(super) orbs: Vec<([f64; 3], u32)>, + /// and the merge id it drew from the level random. + pub(super) orbs: Vec<([f64; 3], u32, i32)>, /// Block entities the behaviour removed, for the game's own follow-ups /// (open screens, playing songs). pub(super) removed: Vec<([i32; 3], BlockEntity)>, @@ -342,8 +342,8 @@ impl GameLevel<'_> { while amount > 0 { let value = removal::orb_value(amount); amount -= value; - let _merge_id = self.world.random.next_int_bound(40); - self.spill.orbs.push((at, value)); + let merge_id = self.world.random.next_int_bound(40).unwrap_or(0); + self.spill.orbs.push((at, value, merge_id)); } } /// `BlockEntity.preRemoveSideEffects` and `removeBlockEntity` for the block @@ -398,8 +398,12 @@ impl GameLevel<'_> { } } PreRemove::Campfire => { - for _ in 0..4 { - self.drop_item_stack(pos, None); + let items = match &entity { + Some(BlockEntity::Campfire(c)) => c.items.clone(), + _ => Default::default(), + }; + for stack in items { + self.drop_item_stack(pos, stack); } } PreRemove::Jukebox => { @@ -579,8 +583,8 @@ impl Game<'_> { let motion = s.motion.unwrap_or_else(new_item_motion); self.spawn_item(s.stack, s.pos, motion, s.delay); } - for (at, value) in spill.orbs { - self.add_orb(at, value); + for (at, value, merge_id) in spill.orbs { + self.award_orb(at, value, merge_id); } for (pos, entity) in spill.removed { let key = (pos[0], pos[1], pos[2]); diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index f7f05e1c4..c5ddcf9a1 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -135,8 +135,10 @@ pub(super) struct Station { pub(super) offers: EnchantOffers, bookcases: i32, pub(super) anvil: AnvilResult, - /// Anvil rename box text. + /// Anvil rename box text (`AnvilMenu.itemName`). pub(super) name: String, + /// The box itself (`AnvilScreen`'s `EditBox` of 50, unbordered). + pub(super) rename: horizon_client::ui::EditBox, /// Stonecutter: the menu's recipe list (horizon_client::menu), its /// `selectedRecipeIndex` and the screen's first shown index. pub(super) stone: horizon_client::menu::Stonecutter, @@ -146,8 +148,6 @@ pub(super) struct Station { anvil_last: Option, /// Anvil on a server: the name last sent (the client menu's `itemName`). pub(super) anvil_sent: Option, - /// When the screen opened: the rename box has the focus from then. - opened: std::time::Instant, pub(super) last_sound: u64, /// Villager trading state. pub(super) merchant: Option>, @@ -169,12 +169,12 @@ impl Station { bookcases: 0, anvil: AnvilResult::default(), name: String::new(), + rename: anvil_box(), stone: Default::default(), selected: None, start: 0, anvil_last: None, anvil_sent: None, - opened: std::time::Instant::now(), last_sound: 0, merchant: None, beacon: [None; 2], @@ -713,7 +713,8 @@ impl Game<'_> { if let Some(rest) = self.give(stack) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40); + // Inventory.placeItemBackInInventory: `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } /// `Player.giveExperienceLevels`. @@ -868,6 +869,9 @@ impl Game<'_> { if kind == Kind::Stonecutter && self.stonecutter_click(local) { return true; } + if kind == Kind::Anvil && !right && self.anvil_name_click(ox, oy) { + return true; + } } let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else { return true; @@ -894,6 +898,8 @@ impl Game<'_> { .as_ref() .map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i)) .unwrap_or_default(); + let name = s.name.clone(); + s.rename.set_value(&name); } self.station_changed(); } @@ -960,21 +966,29 @@ impl Game<'_> { let Some(s) = self.station.as_mut() else { return; }; - let cleared = self.text_selection.take(ANVIL_NAME, &mut s.name); if backspace { - if !cleared { - s.name.pop(); - } + s.rename.delete_chars(-1); } else if let Some(t) = text { - for c in t.chars().filter(|c| !c.is_control()) { - if s.name.chars().count() < ANVIL_NAME_CHARS { - s.name.push(c); - } - } + s.rename.insert_text(t); } + s.name.clone_from(&s.rename.value); self.station_changed(); self.remote_anvil_rename(); } + /// `AnvilScreen.keyPressed`: the rename box handles the key. + pub(super) fn station_key(&mut self, key: winit::keyboard::KeyCode) -> bool { + let (ctrl, shift) = (self.has_control_down(), self.has_shift_down()); + let Some(s) = self.station.as_mut() else { + return false; + }; + if !s.rename.key(key, ctrl, shift) { + return false; + } + s.name.clone_from(&s.rename.value); + self.station_changed(); + self.remote_anvil_rename(); + true + } /// Ctrl+A/C/V/X in the anvil's rename box (an `EditBox` of 50) or the /// recipe book's search box. pub(super) fn station_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) { @@ -985,18 +999,52 @@ impl Game<'_> { let Some(s) = self.station.as_mut() else { return; }; - if Self::append_shortcut( - &mut self.text_selection, - &mut self.clipboard, - ANVIL_NAME, - &mut s.name, - shortcut, - ANVIL_NAME_CHARS, - ) { + if s.rename.shortcut(shortcut, &mut self.clipboard) { + s.name.clone_from(&s.rename.value); self.station_changed(); self.remote_anvil_rename(); } } + /// A click in the anvil's name box (`EditBox.onClick`). + fn anvil_name_click(&mut self, ox: f32, oy: f32) -> bool { + let open = self + .station + .as_ref() + .is_some_and(|s| s.kind == Kind::Anvil && s.inputs[0].is_some()); + if !open { + return false; + } + let (x, y, w, h) = (ox + 62.0, oy + 24.0, 103.0, 12.0); + let (mx, my) = self.cursor; + if mx < x || my < y || mx >= x + w || my >= y + h { + return false; + } + let shift = self.has_shift_down(); + let Some(s) = self.station.as_mut() else { + return false; + }; + s.rename.x = x; + s.rename.y = y; + s.rename.width = w; + s.rename.click(&self.gui.atlas, mx, shift, false); + true + } + /// AnvilScreen's name box, drawn after the station picture. + fn draw_anvil_name(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) { + let Some(s) = self.station.as_mut() else { + return; + }; + let edit = &mut s.rename; + edit.x = ox + 62.0; + edit.y = oy + 24.0; + edit.width = 103.0; + edit.height = 12.0; + edit.bordered = false; + edit.focused = true; + edit.text_color = u32::MAX; + edit.max_length = ANVIL_NAME_CHARS; + edit.draw(batch, &self.gui.atlas, None); + } // ---------------------------------------------------------------- drawing @@ -1094,6 +1142,10 @@ impl Game<'_> { let (ox, oy) = self.inventory_origin(batch); let local = (self.cursor.0 - ox, self.cursor.1 - oy); let mut tooltip: Option> = None; + let mut anvil_name = false; + // Keep item tooltips as stacks until drawing: text alone loses the + // bundle contents component (including in the player's inventory). + let mut tooltip_stack: Option = None; { let atlas = &self.gui.atlas; batch.fill( @@ -1428,18 +1480,8 @@ impl Game<'_> { ); } if s.inputs[0].is_some() { - // AnvilScreen's name box: white, 103 wide, no inverted highlight. - horizon_client::ui::append_field( - batch, - atlas, - &s.name, - (ox + 62.0, oy + 24.0, 103.0), - (u32::MAX, true), - ANVIL_NAME_CHARS, - self.text_selection.covers(ANVIL_NAME, &s.name), - false, - s.opened.elapsed().as_millis(), - ); + // Drawn once this borrow of the station ends. + anvil_name = true; } let cost = s.anvil.cost; if cost > 0 { @@ -1579,13 +1621,16 @@ impl Game<'_> { ); self.draw_item(batch, &result, ox + x, oy + y); if hover { - tooltip = Some(self.stack_tooltip(&result)); + tooltip_stack = Some(result); } } } } } } + if anvil_name { + self.draw_anvil_name(batch, ox, oy); + } let drag = self.drag_view(); for i in self.station_slots().unwrap_or_default() { let Some((sx, sy)) = self.slot_position_any(i) else { @@ -1606,13 +1651,16 @@ impl Game<'_> { ); if self.carried.is_none() && tooltip.is_none() + && tooltip_stack.is_none() && let Some(stack) = self.slot_mut(slot).clone() { - tooltip = Some(self.stack_tooltip(&stack)); + tooltip_stack = Some(stack); } } if self.carried.is_some() { self.draw_carried(batch); + } else if let Some(stack) = tooltip_stack { + self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1); } else if let Some(lines) = tooltip { self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); } @@ -1656,6 +1704,8 @@ impl Game<'_> { }; let name = if let Some(n) = stack.custom_name() { n.to_owned() + } else if let Some(n) = stack.item_name() { + n } else if stack.data().is_some_and(|d| d.ominous_banner) { self.translate("block.minecraft.ominous_banner", "Ominous Banner") } else if stack.data().and_then(|d| d.potion.as_ref()).is_some() @@ -1682,7 +1732,9 @@ impl Game<'_> { // ItemContainerContents.addToTooltip. let hover_name = |s: &ItemStack| { s.custom_name() - .map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned) + .map(str::to_owned) + .or_else(|| s.item_name()) + .unwrap_or_else(|| self.pack.names.display_item(&s.item)) }; if display.shows("minecraft:container") { for text in @@ -1907,32 +1959,65 @@ impl Game<'_> { if lines.is_empty() { return; } - let lines: Vec<_> = lines + let lines = Self::resolved(lines); + let atlas = &self.gui.atlas; + let (x, y, w, h) = + Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), 0.0); + Self::tooltip_box(batch, atlas, x, y, w, h); + Self::tooltip_text(batch, atlas, &lines, x, y); + } + /// A tooltip's lines with their legacy codes resolved and their colours. + fn resolved>( + lines: &[(T, u32)], + ) -> Vec<(horizon_client::text::Text, u32)> { + lines .iter() .map(|(text, color)| { let text: horizon_client::text::Text = text.clone().into(); (text.resolve_legacy(), *color) }) - .collect(); - let atlas = &self.gui.atlas; + .collect() + } + /// The tooltip's text's top-left corner and the box's size: beside the + /// cursor, kept on screen, `extra` pixels taller than the text. + fn tooltip_geometry( + atlas: &horizon_client::hud::HudAtlas, + lines: &[(horizon_client::text::Text, u32)], + (x, y): (f32, f32), + (width, height): (f32, f32), + extra: f32, + ) -> (f32, f32, f32, f32) { let w = lines .iter() .map(|(t, _)| atlas.font.width_text(t)) - .fold(0.0, f32::max); - let h = if lines.len() == 1 { + .fold(0.0, f32::max) + .max(if extra > 0.0 { 96.0 } else { 0.0 }); + let text_h = if lines.len() == 1 { 8.0 } else { 8.0 + 2.0 + (lines.len() - 1) as f32 * 10.0 }; + let h = text_h + extra; let mut x = x + 12.0; let mut y = y - 12.0; - if x + w > batch.width - 4.0 { + if x + w > width - 4.0 { x = (x - 28.0 - w).max(4.0); } - if y + h + 3.0 > batch.height { - y = batch.height - h - 3.0; + if y + h + 3.0 > height { + y = height - h - 3.0; } y = y.max(4.0); + (x, y, w, h) + } + /// The box and its frame sprites. + fn tooltip_box( + batch: &mut HudBatch, + atlas: &horizon_client::hud::HudAtlas, + x: f32, + y: f32, + w: f32, + h: f32, + ) { batch.fill(atlas, x - 3.0, y - 4.0, w + 6.0, 1.0, rgba(16, 0, 16, 240)); batch.fill( atlas, @@ -1968,12 +2053,137 @@ impl Game<'_> { h + 4.0, rgba(60, 0, 190, 80), ); + } + fn tooltip_text( + batch: &mut HudBatch, + atlas: &horizon_client::hud::HudAtlas, + lines: &[(horizon_client::text::Text, u32)], + x: f32, + y: f32, + ) { let mut ty = y; for (i, (text, color)) in lines.iter().enumerate() { batch.text_styled(atlas, text, x, ty, *color, true); ty += if i == 0 { 12.0 } else { 10.0 }; } } + /// A stack's tooltip: its lines, and under them a bundle's contents + /// (`ClientBundleTooltip`: the 4 wide grid, the `+N` of what it leaves + /// out, and the weight bar). + pub(super) fn draw_stack_tooltip( + &mut self, + batch: &mut HudBatch, + stack: &ItemStack, + x: f32, + y: f32, + ) { + let lines = Self::resolved(&self.stack_tooltip(stack)); + if lines.is_empty() { + return; + } + // The component's height does not depend on where it is drawn, so a + // probe at the origin gives the box its extra height. + let probe = horizon_client::bundle::tooltip_layout(self.res.items(), stack, 0.0, 0.0, 96.0); + let extra = probe.as_ref().map_or(0.0, |l| l.height + 2.0); + let atlas = &self.gui.atlas; + let (x, y, w, h) = + Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), extra); + Self::tooltip_box(batch, atlas, x, y, w, h); + let text_h = h - extra; + Self::tooltip_text(batch, atlas, &lines, x, y); + let Some(layout) = + horizon_client::bundle::tooltip_layout(self.res.items(), stack, x, y + text_h + 2.0, w) + else { + return; + }; + self.draw_bundle_tooltip(batch, &layout); + } + /// `ClientBundleTooltip.extractImage`: the slots, the count past them and + /// the progress bar. + fn draw_bundle_tooltip( + &mut self, + batch: &mut HudBatch, + layout: &horizon_client::bundle::TooltipLayout, + ) { + for (at, stack) in &layout.slots { + // Each draw takes the atlas on its own: the item's needs `&mut self`. + horizon_client::ui::nine_slice( + batch, + &self.gui.atlas, + "gui/sprites/container/bundle/slot_background", + at[0], + at[1], + 24.0, + 24.0, + u32::MAX, + ); + let stack = stack.clone(); + let [x, y] = *at; + self.draw_item(batch, &stack, x + 4.0, y + 4.0); + } + let atlas = &self.gui.atlas; + if let Some((at, hidden)) = layout.overflow { + let label = horizon_client::text::Text::literal(format!("+{hidden}")); + let w = atlas.font.width_text(&label); + batch.text_styled( + atlas, + &label, + at[0] + 12.0 - (w / 2.0).floor(), + at[1] + 10.0, + u32::MAX, + true, + ); + } + if let Some((at, description)) = layout + .empty_description + .zip(Some("item.minecraft.bundle.empty.description")) + { + let text = horizon_client::text::Text::translate(description, &[]); + let mut ty = at[1]; + for line in atlas.font.split_lines(&text, layout.width) { + batch.text_styled(atlas, &line.0, at[0], ty, 0xFFAA_AAAA, false); + ty += 9.0; + } + } + let [bx, by] = layout.bar; + let fill_sprite = if layout.full { + "gui/sprites/container/bundle/bundle_progressbar_full" + } else { + "gui/sprites/container/bundle/bundle_progressbar_fill" + }; + horizon_client::ui::nine_slice( + batch, + atlas, + fill_sprite, + bx + 1.0, + by, + layout.fill, + 13.0, + u32::MAX, + ); + horizon_client::ui::nine_slice( + batch, + atlas, + "gui/sprites/container/bundle/bundle_progressbar_border", + bx, + by, + 96.0, + 13.0, + u32::MAX, + ); + if let Some(key) = layout.label { + let label = horizon_client::text::Text::translate(key, &[]); + let w = atlas.font.width_text(&label); + batch.text_styled( + atlas, + &label, + bx + 48.0 - (w / 2.0).floor(), + by + 3.0, + u32::MAX, + true, + ); + } + } /// Hovered-slot tooltip on the plain inventory, crafting and container screens. pub(super) fn draw_slot_tooltip(&mut self, batch: &mut HudBatch) { if self.carried.is_some() { @@ -1984,8 +2194,7 @@ impl Game<'_> { }; let stack = self.slot_view(slot); if let Some(stack) = stack { - let lines = self.stack_tooltip(&stack); - self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); + self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1); } else if let Some(text) = self.crafter_tooltip(slot) { self.draw_tooltip_lines( batch, @@ -2041,5 +2250,13 @@ impl Game<'_> { } /// The anvil's rename box (`AnvilScreen`: `setMaxLength(50)`). -const ANVIL_NAME: horizon_client::clipboard::FieldId = ("anvil.name", 0); const ANVIL_NAME_CHARS: usize = 50; + +fn anvil_box() -> horizon_client::ui::EditBox { + let mut edit = horizon_client::ui::EditBox::new(0.0, 0.0, 103.0, 12.0); + edit.bordered = false; + edit.max_length = ANVIL_NAME_CHARS; + edit.text_color = u32::MAX; + edit.focused = true; + edit +} diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index 1bcb6a524..e5cb0ab26 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -33,6 +33,55 @@ impl Game<'_> { self.container = Some(key); self.ender_open = Some(key); } + /// The block above `key` is a redstone conductor. + fn conductor_above(&self, key: (i32, i32, i32)) -> bool { + let above = self.world.block(key.0, key.1 + 1, key.2); + self.res.behaviours().facts(above).flags + & horizon_world::level::data::state_flags::CONDUCTOR + != 0 + } + /// `ChestBlock.isChestBlockedAt`: a redstone conductor above the chest or + /// a cat sitting on it. + fn chest_blocked_at(&self, key: (i32, i32, i32)) -> bool { + if self.conductor_above(key) { + return true; + } + // isCatSittingOnChest: a cat whose box meets the block above. + let area = horizon_core::geometry::Aabb::new( + horizon_core::geometry::Vec3::new( + f64::from(key.0), + f64::from(key.1 + 1), + f64::from(key.2), + ), + horizon_core::geometry::Vec3::new( + f64::from(key.0 + 1), + f64::from(key.1 + 2), + f64::from(key.2 + 1), + ), + ); + self.mobs.iter().any(|m| { + m.species() == horizon_client::mobs::Species::Cat + && m.alive() + && horizon_client::mobs::tame::cat_sitting(m) + && m.bounds().intersects(area) + }) + } + /// `ChestBlock.getMenuProvider` (`combine(.., false)`) and + /// `EnderChestBlock.useWithoutItem`: a blocked chest, or a double chest + /// with either half blocked, opens no menu. + pub(super) fn chest_menu_blocked(&self, key: (i32, i32, i32), name: &str) -> bool { + match name { + "minecraft:chest" | "minecraft:trapped_chest" => { + self.chest_blocked_at(key) + || self + .chest_partner(key) + .is_some_and(|(other, _)| self.chest_blocked_at(other)) + } + // The ender chest only looks at the block above (no cat check). + "minecraft:ender_chest" => self.conductor_above(key), + _ => false, + } + } /// Closing it copies the items back and leaves no block-entity contents. pub(super) fn close_ender_chest(&mut self) { let Some(key) = self.ender_open.take() else { diff --git a/crates/horizon-client/src/game/text_displays.rs b/crates/horizon-client/src/game/text_displays.rs index f6c946bcc..45bd64fa7 100644 --- a/crates/horizon-client/src/game/text_displays.rs +++ b/crates/horizon-client/src/game/text_displays.rs @@ -210,6 +210,7 @@ impl Game<'_> { if bg != 0 { batch.fill( atlas, + // submitTextBackground(-1, -1, width, height): corners, not a size. -1.0, -1.0, width + 1.0, diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index 87e0c18c6..6986d8baa 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -803,8 +803,7 @@ impl Game<'_> { text.split('\n').map(|l| (l.to_owned(), u32::MAX)).collect(); self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); } else if let Some(stack) = tooltip { - let lines = self.stack_tooltip(&stack); - self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); + self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1); } true } diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index c31094596..d934e85aa 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -1,7 +1,8 @@ //! Tridents: `TridentItem.releaseUsing` (throw after 10 ticks, or Riptide in //! water or rain) and `ThrownTrident` (arrow flight with 0.99 water inertia, //! 8 damage plus Impaling, the reversed bounce off a hit, sticking, Loyalty's -//! return to the owner, pickup) with `ThrownTridentRenderer`. +//! return to the owner, pickup) with `ThrownTridentRenderer`, and Channeling's +//! lightning on a hit (`channeling.json`). use super::{Game, view_vector}; use horizon_client::{ collisions::SceneCollisions, @@ -10,7 +11,8 @@ use horizon_client::{ mobs::{Damage, DamageKind, Who, anim::Poses}, }; use horizon_core::{ - geometry::{Aabb, Vec3}, + geometry::{Aabb, Vec3, traverse_blocks}, + position::BlockPos, random::RandomSource, }; @@ -154,6 +156,69 @@ impl Game<'_> { p.auto_spin_attack_dmg = 0.0; } } + /// The `summon_entity` and `play_sound` effects of `channeling.json`: in a + /// thunderstorm (`weather_check`) a spot that sees the sky (`can_see_sky`) + /// gets a lightning bolt and the thunder sound. + fn channel_lightning(&mut self, at: [f64; 3]) { + // Level.isThundering: the thunder level, in dimensions with weather. + if self.dim != super::Dim::Overworld || self.world.weather.levels(1.0).1 <= 0.9 { + return; + } + // BlockAndLightGetter.canSeeSky: full sky light. + let (x, y, z) = ( + at[0].floor() as i32, + at[1].floor() as i32, + at[2].floor() as i32, + ); + if self.world.light(x, y, z) >> 4 != 15 { + return; + } + self.spawn_bolt(at, false); + self.audio + .play("minecraft:item.trident.thunder", Some(at), 5.0, 1.0); + } + /// `minecraft:hit_block` of Channeling (`AbstractArrow.hitBlockEnchantmentEffects`): + /// the block the trident's path first hits, if it is a lightning rod, with + /// the hit location clamped into the block (`BlockPos.clampLocationWithin`). + fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3) { + let to = Vec3::new( + from.x + velocity.x, + from.y + velocity.y, + from.z + velocity.z, + ); + let mut scene = SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }; + // ClipContext.Block.COLLIDER with Fluid.NONE. + let hit = traverse_blocks(from, to, 1024, |p: BlockPos| { + let state = self.world.block(p.x, p.y, p.z); + if self.res.facts().is_air(state) { + return None; + } + let shape = self + .collision + .collision_shape(&mut scene, state, [p.x, p.y, p.z]) + .ok()?; + shape.clip(from, to, p) + }); + let Some(hit) = hit.ok().flatten() else { + return; + }; + let state = self.world.block(hit.block.x, hit.block.y, hit.block.z); + if !self.res.facts().get(state).name.ends_with("lightning_rod") { + return; + } + let edge = f64::from(1.0e-5f32); + let clamp = + |v: f64, origin: i32| v.clamp(f64::from(origin) + edge, f64::from(origin) + 1.0 - edge); + self.channel_lightning([ + clamp(hit.location.x, hit.block.x), + clamp(hit.location.y, hit.block.y), + clamp(hit.location.z, hit.block.z), + ]); + } /// `TridentItem.releaseUsing`. pub(super) fn release_trident(&mut self, ticks: u32) { if ticks < 10 { @@ -171,10 +236,11 @@ impl Game<'_> { .as_ref() .map(|p| [p.position.x, p.position.y, p.position.z]); if riptide > 0 { - if !self.in_water_or_rain() { + // `isInWaterOrRain() && !isPassenger()`. + if !self.in_water_or_rain() || self.riding.is_some() { return; } - self.damage_held(1); + self.hurt_held_without_breaking(1); // trident_spin_attack_strength: 1.5 + 0.75 per level above the first. let strength = 1.5 + 0.75 * (riptide as f32 - 1.0); let d = view_vector(self.yaw, self.pitch); @@ -195,12 +261,8 @@ impl Game<'_> { self.audio.play(&sound, at, 1.0, 1.0); return; } - // hurtWithoutBreaking, then the thrown copy. - if !self.creative - && let Some(s) = self.held_mut().as_mut() - { - s.damage += 1; - } + // hurtWithoutBreaking (Unbreaking applies), then the thrown copy. + self.hurt_held_without_breaking(1); let thrown = self.held().cloned().unwrap_or(stack); if !self.creative { *self.held_mut() = None; @@ -311,9 +373,10 @@ impl Game<'_> { }); let damage = 8.0 + if aquatic { 2.5 * impaling } else { 0.0 }; t.dealt_damage = true; + let mut hurt_at = None; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { let from = Some([t.position.x, t.position.y, t.position.z]); - m.damage( + let hurt = m.damage( damage, Damage { kind: DamageKind::Arrow, @@ -321,6 +384,15 @@ impl Game<'_> { from, }, ); + if hurt { + hurt_at = Some([m.position.x, m.position.y, m.position.z]); + } + } + // `minecraft:post_attack` of Channeling, on the victim. + if let Some(at) = hurt_at + && t.stack.enchantment_level("minecraft:channeling") > 0 + { + self.channel_lightning(at); } self.audio.play( "minecraft:item.trident.hit", @@ -341,6 +413,7 @@ impl Game<'_> { subject: None, }; if let Ok(moved) = scene.resolve(bounds(t.position), t.velocity, 0.0, false) { + let (from, flight) = (t.position, t.velocity); let blocked = (moved.x - t.velocity.x).abs() > 1e-7 || (moved.y - t.velocity.y).abs() > 1e-7 || (moved.z - t.velocity.z).abs() > 1e-7; @@ -350,6 +423,9 @@ impl Game<'_> { t.position.z + moved.z, ); if blocked { + if t.stack.enchantment_level("minecraft:channeling") > 0 { + self.channel_rod_hit(from, flight); + } t.in_ground = true; t.velocity = Vec3::ZERO; self.audio.play( diff --git a/crates/horizon-client/src/game/villagers.rs b/crates/horizon-client/src/game/villagers.rs index 3cfe8aa1d..c04b18604 100644 --- a/crates/horizon-client/src/game/villagers.rs +++ b/crates/horizon-client/src/game/villagers.rs @@ -21,6 +21,38 @@ impl Game<'_> { fn item_max_stack(&self, item: &str) -> u32 { self.res.items().get(item).map_or(64, |i| i.max_stack) } + /// `WorkAtComposter.useWorkstation`: at a composter the farmer makes + /// bread, then composts seeds. + pub(super) fn villager_work_at_composter(&mut self, id: u32, pos: [i32; 3]) { + let Some(i) = self.villager_index(id) else { + return; + }; + if self.composter_level(pos).is_none() { + return; + } + self.villager_make_bread(i); + self.villager_compost_items(i, pos); + } + /// `WorkAtComposter.makeBread`: up to three loaves from nine wheat while + /// the villager holds at most 36 bread; what does not fit drops. + fn villager_make_bread(&mut self, i: usize) { + let max_stack = self.item_max_stack("minecraft:bread"); + let e = villager::ext_mut(&mut self.mobs[i]); + if e.count_item("minecraft:bread") > 36 { + return; + } + let loaves = (e.count_item("minecraft:wheat") / 3).min(3); + if loaves == 0 { + return; + } + e.remove_item("minecraft:wheat", loaves * 3); + let left = e.add_item(ItemStack::new("minecraft:bread", loaves), max_stack); + if let Some(rest) = left { + // spawnAtLocation(level, stack, 0.5F) + let p = self.mobs[i].position; + self.spawn_item(rest, [p.x, p.y + 0.5, p.z], [0.0, 0.2, 0.0], 10); + } + } /// `BehaviorUtils.throwItem`: toss a stack from a villager's hand toward /// a point. fn villager_throw(&mut self, from: Vec3, eye: f64, to: Vec3, stack: ItemStack) { diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index 0cea31fd0..e9ee8e3e8 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -46,6 +46,10 @@ impl WorldRequest { options: &Options, settings: &Settings, ) -> Result { + // Before any chunk is read: a loaded container with a loot table is rolled as its chunk + // is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world + // opens. Every world opens here: in-frame, on the server thread, on a dedicated server. + horizon_client::structure_blocks::loot_data(&options.resources); let Self { save_root, folder, @@ -261,6 +265,7 @@ pub(super) fn make_streaming( res.threads(), ) }; + streaming.forced = save.load_forced_chunks(); streaming.radius = settings.render_distance; streaming.biome_blend = settings.biome_blend; streaming.smooth_lighting = settings.smooth_lighting; diff --git a/crates/horizon-client/src/game/zombie_reinforcements.rs b/crates/horizon-client/src/game/zombie_reinforcements.rs new file mode 100644 index 000000000..4c6100f43 --- /dev/null +++ b/crates/horizon-client/src/game/zombie_reinforcements.rs @@ -0,0 +1,134 @@ +//! `Zombie.hurtServer`'s reinforcements: a hurt zombie on Hard may call +//! another of its kind from 7 to 40 blocks away to fight its target. +use super::Game; +use horizon_client::mobs::{ + MobType, Species, Who, + finalize::{self, Attr, Op}, + spawn_rules::{self, Reason}, + spawn_selection::{self, NaturalLevel}, + spawner::SpawnerLevel, +}; +use horizon_core::random::{LegacyRandom, RandomSource}; +use horizon_world::food::Difficulty; + +/// `Zombie.REINFORCEMENT_ATTEMPTS`. +const ATTEMPTS: usize = 50; +/// `Zombie.REINFORCEMENT_RANGE_MIN` and `REINFORCEMENT_RANGE_MAX`. +const RANGE_MIN: i32 = 7; +const RANGE_MAX: i32 = 40; + +/// One axis of the offset: `Mth.nextInt(random, 7, 40) * Mth.nextInt(random, -1, 1)`. +fn offset(random: &mut LegacyRandom) -> i32 { + let distance = random + .next_int_between_inclusive(RANGE_MIN, RANGE_MAX) + .unwrap_or(RANGE_MIN); + distance * random.next_int_between_inclusive(-1, 1).unwrap_or(0) +} + +impl Game<'_> { + /// `Zombie.hurtServer` after the hit: on Hard, with the zombie's chance + /// and monsters spawning, a copy of it appears out of sight and takes + /// `target`; both then call less often. + pub(super) fn call_reinforcements(&mut self, caller: u32, target: Who) { + let Some(index) = self.mobs.iter().position(|m| m.id == caller) else { + return; + }; + if self.world.health_rules.difficulty != Difficulty::Hard { + return; + } + let zombie = &mut self.mobs[index]; + if f64::from(zombie.random.next_float()) >= zombie.attrs.spawn_reinforcements { + return; + } + // ServerLevel.isSpawningMonsters. + if !self.ui.rules.bool("spawn_mobs") || !self.ui.rules.bool("spawn_monsters") { + return; + } + let kind = zombie.kind.clone(); + let from = [ + zombie.position.x.floor() as i32, + zombie.position.y.floor() as i32, + zombie.position.z.floor() as i32, + ]; + let mut own = std::mem::replace(&mut zombie.random, LegacyRandom::new(0)); + let mut rules = std::mem::replace(&mut self.world.random, LegacyRandom::new(0)); + let spot = self.reinforcement_spot(&kind, from, &mut own, &mut rules); + self.mobs[index].random = own; + self.world.random = rules; + let Some(spot) = spot else { + return; + }; + let Some(id) = self.spawn_mob(&kind.id, spot.map(f64::from), false) else { + return; + }; + if let Some(reinforcement) = self.mobs.iter_mut().find(|m| m.id == id) { + reinforcement.ai.target = Some(target); + // ZOMBIE_REINFORCEMENT_CALLEE_CHARGE. + finalize::add_modifier( + reinforcement, + Attr::SpawnReinforcements, + "minecraft:reinforcement_callee_charge", + -f64::from(0.05f32), + Op::AddValue, + ); + } + // The caller's charge grows by 0.05 each time it calls. + let caller = &mut self.mobs[index]; + let charged = finalize::modifier_amount( + caller, + Attr::SpawnReinforcements, + "minecraft:reinforcement_caller_charge", + ) + .unwrap_or(0.0); + finalize::add_modifier( + caller, + Attr::SpawnReinforcements, + "minecraft:reinforcement_caller_charge", + charged - 0.05, + Op::AddValue, + ); + } + /// The block `Zombie.hurtServer` puts the reinforcement at: the first of + /// 50 random offsets with a valid spawn surface, no alive player within + /// 7 blocks, and room for the zombie standing on the block's corner. + fn reinforcement_spot( + &self, + kind: &MobType, + from: [i32; 3], + own: &mut LegacyRandom, + rules: &mut LegacyRandom, + ) -> Option<[i32; 3]> { + let level = self.natural_level(); + let (width, height) = (kind.width as f32, kind.height as f32); + for _ in 0..ATTEMPTS { + let [x, y, z] = from.map(|v| v + offset(own)); + if !spawn_rules::is_spawn_position_ok(kind.species, &level, x, y, z) + || !spawn_rules::check_spawn_rules( + kind.species, + &level, + Reason::Reinforcement, + rules, + x, + y, + z, + ) + { + continue; + } + // setPos(x, y, z): the box is centred on the block's corner. + let bounds = + spawn_selection::spawn_box(f64::from(x), f64::from(y), f64::from(z), width, height); + // Drowned.canSpawnInLiquids. + let wet_ok = kind.species == Species::Drowned + || !spawn_selection::contains_any_liquid(&level, bounds); + if !level.alive_player_near(f64::from(x), f64::from(y), f64::from(z), 7.0) + && level.unobstructed(bounds) + && level.no_collision(bounds) + && wet_ok + { + return Some([x, y, z]); + } + } + None + } +} diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 80cf1140e..70d882a98 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -32,6 +32,8 @@ fn entity_shader() -> String { crate::overlay::wgsl_constants() + include_str!("entity.wgsl") } +#[cfg(test)] +mod crumbling_tests; mod entity_batch; mod weather; use entity_batch::EntityBatch; @@ -65,9 +67,13 @@ mod arena; mod cull; mod end_sky; mod flat; +mod hdr; pub mod lightning; mod lod; +mod native_material; +mod native_pack; mod native_post; +mod native_sky; mod occlusion; mod pack_atlas; pub mod pack_draw; @@ -86,6 +92,7 @@ mod stars; mod timing; pub mod translucency; pub(crate) mod upload; +mod wgsl_prefix; mod world_post; /// A column whose every section has its mesh (`Renderer::lod_detail_columns`). @@ -329,6 +336,10 @@ pub struct Renderer { adapter: wgpu::Adapter, /// The window's swapchain; `None` when rendering offscreen (`--offscreen`). surface: Option>, + /// The surface `release_surface` set aside for the renderer that replaces + /// this one on the same device: reused, not created again, as a new + /// surface for the window can come back lost (Wayland on Mesa). + spare_surface: std::sync::Mutex>>, /// The offscreen colour target, sized like `config`. offscreen: Option, /// Offscreen submissions still on the GPU: at most two frames run ahead. @@ -426,6 +437,8 @@ pub struct Renderer { /// The mipmap levels sampled (the Mipmap Levels option), at most `mip_levels - 1`. mipmaps: u32, terrain_layout: wgpu::BindGroupLayout, + /// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`, for a shader pack's terrain. + terrain_sprites: [f64; 2], sprite_sampler: wgpu::Sampler, lightmap_view: wgpu::TextureView, lightmap_sampler: wgpu::Sampler, @@ -457,7 +470,7 @@ pub struct Renderer { lod_retiring: crate::fast_hash::FxHashSet, /// Finished tile meshes `upload_lods` has yet to upload. lod_pending: Option, - detail_columns: HashMap<(i32, i32), u32>, + detail_columns: crate::fast_hash::FxHashMap<(i32, i32), u32>, /// Bumped whenever a column becomes or stops being complete. detail_revision: u64, arena: arena::Arena, @@ -491,17 +504,21 @@ pub struct Renderer { /// The shadow camera's draws by page, in bands of distance from the light. shadow_runs: page_runs::PageRuns, /// Block-entity blocks per meshed section (world position, state id). - block_entities: HashMap>, + block_entities: crate::fast_hash::FxHashMap>, /// Changes whenever `block_entities` does (unique across renderers). block_entity_revision: u64, /// VisGraph face connectivity per meshed section (cave culling). occlusion: occlusion::Occlusion, /// Resolution scale and FXAA (Horizon settings). world_post: world_post::Post, + /// HDR output (Horizon setting): `Some` while the swapchain is scRGB. + hdr: Option, + /// Whether the window's surface can present scRGB. + hdr_capable: bool, /// The shader pack's post-processing, and the dimension (legacy id) it was loaded for. pack_post: Option<(i32, Option)>, /// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s. - native_post: Option, + native: Option, /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). pass_timing: bool, /// The entity vertices Horizon draws itself while a shader pack draws the others. @@ -904,15 +921,24 @@ async fn open_device( limits.max_storage_buffer_binding_size, limits.max_texture_array_layers, )); - match adapter - .request_device(&wgpu::DeviceDescriptor { - label: Some("Horizon device"), - required_features: features, - required_limits: limits, - ..Default::default() - }) - .await - { + let desc = wgpu::DeviceDescriptor { + label: Some("Horizon device"), + required_features: features, + required_limits: limits, + ..Default::default() + }; + #[cfg(not(target_os = "macos"))] + let opened = match info.backend { + wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc), + _ => None, + }; + #[cfg(target_os = "macos")] + let opened = None; + let opened = match opened { + Some(opened) => opened, + None => adapter.request_device(&desc).await, + }; + match opened { Ok((device, queue)) => { return Ok(Opened { instance, @@ -1024,7 +1050,15 @@ impl Renderer { surface: if offscreen { None } else { - Some(renderer.instance.create_surface(window.clone())?) + let spare = renderer + .spare_surface + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + Some(match spare { + Some(surface) => surface, + None => renderer.instance.create_surface(window.clone())?, + }) }, instance: renderer.instance.clone(), adapter: renderer.adapter.clone(), @@ -1037,7 +1071,7 @@ impl Renderer { let info = adapter.get_info(); crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info)); let size = window.inner_size(); - let (config, present_modes) = if let Some(surface) = &surface { + let (config, present_modes, hdr_capable) = if let Some(surface) = &surface { let mut config = surface .get_default_config(&adapter, size.width.max(1), size.height.max(1)) .ok_or("surface has no supported configuration")?; @@ -1065,7 +1099,7 @@ impl Renderer { )); } surface.configure(&device, &config); - (config, caps.present_modes.clone()) + (config, caps.present_modes.clone(), hdr::supported(&caps)) } else { let format = wgpu::TextureFormat::Bgra8Unorm; let config = wgpu::SurfaceConfiguration { @@ -1079,7 +1113,7 @@ impl Renderer { view_formats: vec![format], color_space: wgpu::SurfaceColorSpace::default(), }; - (config, Vec::new()) + (config, Vec::new(), false) }; let offscreen = surface .is_none() @@ -1923,14 +1957,20 @@ impl Renderer { compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: config.format.remove_srgb_suffix(), - // RenderPipelines.CELESTIAL: additive (src alpha, one). + // RenderPipelines.CELESTIAL: additive (src alpha, one). The + // alpha goes to 0: HDR output (hdr.wgsl) lets the sun and + // moon shine; nothing else reads the frame's alpha. blend: Some(wgpu::BlendState { color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::SrcAlpha, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add, }, - alpha: wgpu::BlendComponent::OVER, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::Zero, + operation: wgpu::BlendOperation::Add, + }, }), write_mask: wgpu::ColorWrites::ALL, })], @@ -2294,6 +2334,7 @@ impl Renderer { instance, adapter, surface, + spare_surface: std::sync::Mutex::new(None), offscreen, in_flight: std::collections::VecDeque::new(), first_frame: if previous.is_none() { @@ -2367,6 +2408,7 @@ impl Renderer { mip_levels, mipmaps: mip_levels - 1, terrain_layout: bindings, + terrain_sprites: sprite_constants.map(|(_, v)| v), sprite_sampler, lightmap_view, lightmap_sampler, @@ -2388,7 +2430,7 @@ impl Renderer { lod_cutout: Default::default(), lod_retiring: Default::default(), lod_pending: None, - detail_columns: HashMap::new(), + detail_columns: Default::default(), detail_revision: 0, arena, sorted_indices, @@ -2409,12 +2451,14 @@ impl Renderer { solid_runs: page_runs::PageRuns::default(), phase2_runs: page_runs::PageRuns::default(), shadow_runs: page_runs::PageRuns::default(), - block_entities: HashMap::new(), + block_entities: Default::default(), block_entity_revision: next_block_entity_revision(), occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16), world_post, + hdr: None, + hdr_capable, pack_post: None, - native_post: None, + native: None, pass_timing: pack_timing::PassTiming::logging(), pack_entity_rest: Vec::new(), cloud_bindings, @@ -2470,19 +2514,38 @@ impl Renderer { view_formats: &config.view_formats, }) } - /// Release the window surface before another renderer uses the same window. + /// Hand the window surface over to the renderer that replaces this one on + /// the same device (it takes `spare_surface`). pub fn release_surface(&mut self) { - self.surface = None; + *self + .spare_surface + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.surface.take(); } /// Reattach a window after surface loss or a failed renderer replacement. pub fn recreate_surface(&mut self) -> Result<()> { if self.offscreen.is_some() { return Ok(()); } - // Wayland allows only one tearing-control object per window surface. - self.release_surface(); - let surface = self.instance.create_surface(self.window.clone())?; - surface.configure(&self.device, &self.config); + // The surface set aside for a replacement that failed, else a new one + // (Wayland allows only one tearing-control object per window surface, + // so the old one goes first). + let spare = self + .spare_surface + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + self.surface = None; + let surface = match spare { + Some(surface) => surface, + None => self.instance.create_surface(self.window.clone())?, + }; + Self::configure( + &self.device, + &surface, + &self.surface_config(), + self.hdr.as_ref(), + ); self.surface = Some(surface); Ok(()) } @@ -2545,7 +2608,12 @@ impl Renderer { && let Some(surface) = &self.surface { self.config.present_mode = mode; - surface.configure(&self.device, &self.config); + Self::configure( + &self.device, + surface, + &self.surface_config(), + self.hdr.as_ref(), + ); } } pub fn resize(&mut self, width: u32, height: u32) { @@ -2556,7 +2624,12 @@ impl Renderer { self.config.width = width; self.config.height = height; if let Some(surface) = &self.surface { - surface.configure(&self.device, &self.config); + Self::configure( + &self.device, + surface, + &self.surface_config(), + self.hdr.as_ref(), + ); } else { self.offscreen = Some(Self::offscreen_target(&self.device, &self.config)); } @@ -2845,20 +2918,106 @@ impl Renderer { self.detail_columns.insert(column, next); } } - pub fn remove(&mut self, key: SectionKey) { - self.set_detail(key, false); - if self.block_entities.remove(&key).is_some() { + /// Every section of these columns goes: one lookup per column where the + /// tables are by column, and the meshes' ranges freed in one batch. A + /// teleport removes a few thousand columns in one frame. + pub fn remove_columns(&mut self, columns: impl IntoIterator) { + let mut released = Vec::new(); + let mut block_entities = false; + for (x, z) in columns { + if self.detail_columns.remove(&(x, z)) == Some(COMPLETE_COLUMN) { + self.detail_revision += 1; + } + self.occlusion.remove_column(x, z); + let retiring = !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(x, z)); + for sy in 0..crate::chunk::SECTIONS as i32 { + let key = (x, sy, z); + block_entities |= self.block_entities.remove(&key).is_some(); + if retiring { + // Distant tiles still leave its column out: drawn until they don't. + self.lod_retiring.insert(key); + } else if let Some(section) = self.sections.remove(key) { + released.push(section); + } + } + } + if block_entities { self.block_entity_revision = next_block_entity_revision(); } - self.occlusion.remove(key); - if !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(key.0, key.2)) { - // Distant tiles still leave its column out: drawn until they don't. - self.lod_retiring.insert(key); - } else { - self.release_section(key); + if let Some(flat) = &mut self.flat { + for id in released.iter().filter_map(|s| s.flat_id) { + flat.remove(id); + } } + self.sorted_indices + .release_all(released.iter().filter_map(|s| { + let (page, start) = s.sorted_indices?; + Some((page, start, u64::from(s.translucent) * 6)) + })); + self.arena.release_all( + released + .iter() + .map(|s| (s.page, s.start, u64::from(s.solid + s.translucent) * 4)), + ); } /// Horizon's resolution scale (world pixels per window pixel) and FXAA. + /// Configures `surface` and, while HDR output is on, sends its HDR metadata + /// again: a new swapchain starts without it. + fn configure( + device: &wgpu::Device, + surface: &wgpu::Surface<'_>, + config: &wgpu::SurfaceConfiguration, + hdr: Option<&hdr::Hdr>, + ) { + surface.configure(device, config); + if let Some(hdr) = hdr { + horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak); + } + } + fn configure_surface(&self) { + if let Some(surface) = &self.surface { + Self::configure( + &self.device, + surface, + &self.surface_config(), + self.hdr.as_ref(), + ); + } + } + fn send_hdr_metadata(&self) { + if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) { + horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak); + } + } + /// The swapchain's configuration: `config` (what every pipeline draws in), or + /// scRGB while HDR output is on. + fn surface_config(&self) -> wgpu::SurfaceConfiguration { + let mut config = self.config.clone(); + if self.hdr.is_some() { + config.format = hdr::FORMAT; + config.view_formats = vec![hdr::FORMAT]; + config.color_space = hdr::COLOR_SPACE; + config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT; + } + config + } + /// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's + /// highlights expanded up to `peak` nits. Off where the surface can't present it. + pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) { + let on = on && self.hdr_capable && self.surface.is_some(); + if on != self.hdr.is_some() { + self.hdr = + on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix())); + self.configure_surface(); + } + if let Some(hdr) = &mut self.hdr + && (hdr.paper_white, hdr.peak) != (paper_white, peak) + { + hdr.paper_white = paper_white; + hdr.peak = peak; + self.send_hdr_metadata(); + } + } pub fn set_world_post(&mut self, scale: f32, antialias: bool) { self.world_post.scale = scale.clamp(0.25, 2.0); self.world_post.antialias = antialias; @@ -2924,41 +3083,40 @@ impl Renderer { }); self.pack_post = Some((dimension, post)); if self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) { - self.native_post = None; + self.native = None; } report } /// Drops the running shader pack, of either kind. pub fn drop_shader_packs(&mut self) { self.pack_post = None; - self.native_post = None; + self.native = None; self.pack_atlas = None; } - /// Runs the post passes of one of Horizon's own shader packs over the world from now on - /// (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the - /// passes' labels; on an error the passes that ran keep running. + /// Runs the sky, material and post stages of one of Horizon's own shader packs from now + /// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the + /// stages' labels; on an error the pack that ran keeps running. pub fn set_native_shader_pack( &mut self, pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>, ) -> std::result::Result, String> { - let stages = pack.map(|(report, constants)| { - let post: Vec<_> = report - .stages - .into_iter() - .filter(|s| s.kind == horizon_shader_api::StageKind::Post) - .collect(); - (post, constants) - }); - let Some((post, constants)) = stages.filter(|(post, _)| !post.is_empty()) else { - self.native_post = None; + let Some((report, constants)) = pack else { + self.native = None; return Ok(Vec::new()); }; - let format = self.config.format.remove_srgb_suffix(); - let post = native_post::NativePost::new(&self.device, post, &constants, format)?; - let labels = post.labels().into_iter().map(str::to_owned).collect(); - self.pack_post = None; - self.pack_atlas = None; - self.native_post = Some(post); + let terrain = native_material::TerrainShape { + globals: &self.terrain_layout, + flat: self.flat.as_ref().map(|f| &f.draw_layout), + sprites: self.terrain_sprites, + format: self.config.format.remove_srgb_suffix(), + }; + let (pack, labels) = + native_pack::NativePack::new(&self.device, &terrain, report, &constants)?; + self.native = pack.draws().then_some(pack); + if self.native.is_some() { + self.pack_post = None; + self.pack_atlas = None; + } Ok(labels) } /// What a shader pack's gbuffers programs read from Horizon's geometry: the sky's, and @@ -3009,20 +3167,20 @@ impl Renderer { /// The running shader pack's timer, of either kind. fn pass_timer<'a>( pack: &'a mut Option<(i32, Option)>, - native: &'a mut Option, + native: &'a mut Option, ) -> Option<&'a mut Option> { match (pack, native) { (Some((_, Some(post))), _) => Some(&mut post.timing), - (_, Some(post)) => Some(&mut post.timing), + (_, Some(native)) => Some(&mut native.timing), _ => None, } } /// Each pass of the running shader pack with its median GPU time in milliseconds, while /// they are timed (`set_pass_timing`). pub fn shader_pass_times(&self) -> Vec<(String, f32)> { - let timer = match (&self.pack_post, &self.native_post) { + let timer = match (&self.pack_post, &self.native) { (Some((_, Some(post))), _) => post.timing.as_ref(), - (_, Some(post)) => post.timing.as_ref(), + (_, Some(native)) => native.timing.as_ref(), _ => None, }; timer @@ -3195,7 +3353,12 @@ impl Renderer { } Some(wgpu::CurrentSurfaceTexture::Outdated) => { if let Some(surface) = &self.surface { - surface.configure(&self.device, &self.config); + Self::configure( + &self.device, + surface, + &self.surface_config(), + self.hdr.as_ref(), + ); } return Ok(false); } @@ -3207,9 +3370,11 @@ impl Renderer { return Err("surface acquisition failed validation".into()); } }; - let target_texture = match &surface_frame { - Some(f) => f.texture.clone(), - None => self.offscreen.clone().ok_or("no offscreen target")?, + let size = [self.config.width, self.config.height]; + let target_texture = match (&surface_frame, &mut self.hdr) { + (Some(_), Some(hdr)) => hdr.frame(&self.device, size), + (Some(f), None) => f.texture.clone(), + (None, _) => self.offscreen.clone().ok_or("no offscreen target")?, }; // One command buffer for the frame, after the section copies staged // since the last one: Metal commits every command buffer wgpu-core @@ -4052,8 +4217,8 @@ impl Renderer { // The world's targets: the screen, or the scaled target of `post`. let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some()); // One of Horizon's own packs reads the same world target. - let native_post = !frame.title && self.native_post.is_some(); - if let Some(timer) = Self::pass_timer(&mut self.pack_post, &mut self.native_post) + let native_post = !frame.title && self.native.as_ref().is_some_and(|n| n.post.is_some()); + if let Some(timer) = Self::pass_timer(&mut self.pack_post, &mut self.native) && timer.is_some() != self.pass_timing { *timer = self @@ -4115,24 +4280,94 @@ impl Renderer { cull.bind_depth(&self.device, world_depth); } let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0); + let clear = if frame.title || (frame.end_sky && !frame.underwater) { + wgpu::Color::BLACK + } else { + wgpu::Color { + r: f64::from(fog_color[0]), + g: f64::from(fog_color[1]), + b: f64::from(fog_color[2]), + a: 1.0, + } + }; + let sky_visible = sky_open(frame); + // One of Horizon's own packs: this frame's inputs; in a timed frame its sky draws in a + // pass of its own, so that it has a time of its own. + let mut native_sky_pass = false; + if !frame.title + && let Some(native) = &mut self.native + { + let size = self + .world_post + .size() + .filter(|_| scaled.is_some()) + .unwrap_or([self.config.width, self.config.height]); + native.frame.write(&self.queue, frame, size); + if let Some(t) = &mut native.timing { + t.begin(&self.device, &self.queue); + native_sky_pass = + native.sky.is_some() && t.recording() && sky_visible && frame.skybox; + } + } + if native_sky_pass + && let Some(native) = &mut self.native + && let Some(sky) = &native.sky + { + if let Some(t) = &mut native.timing { + let format = self.config.format.remove_srgb_suffix(); + t.mark(&self.device, &mut encoder, (world_view, format)); + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&sky.label), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: world_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(clear), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: world_depth, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.0), + store: wgpu::StoreOp::Discard, + }), + stencil_ops: None, + }), + timestamp_writes: native.timing.as_mut().and_then(|t| t.writes(&sky.label)), + ..Default::default() + }); + pass.set_bind_group(0, &self.group, &[]); + sky.draw(&mut pass, &native.frame); + } + // One of Horizon's own packs with a material stage: terrain draws with its pipelines, + // which read the pack's frame at group 3. + let material = self + .native + .as_ref() + .filter(|_| !frame.title) + .and_then(|n| Some((n.material.as_ref()?, &n.frame.group))); + let solid_pipeline = material.map_or(&self.solid, |(m, _)| &m.solid); + let translucent_pipeline = material.map_or(&self.translucent, |(m, _)| &m.translucent); + let pack_flat = material.and_then(|(m, _)| m.flat.as_ref()); + let pack_frame = |pass: &mut wgpu::RenderPass<'_>| { + if let Some((_, group)) = material { + pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]); + } + }; { let attachments = [Some(wgpu::RenderPassColorAttachment { view: world_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear( - if frame.title || (frame.end_sky && !frame.underwater) { - wgpu::Color::BLACK - } else { - wgpu::Color { - r: f64::from(fog_color[0]), - g: f64::from(fog_color[1]), - b: f64::from(fog_color[2]), - a: 1.0, - } - }, - ), + load: if native_sky_pass { + wgpu::LoadOp::Load + } else { + wgpu::LoadOp::Clear(clear) + }, store: wgpu::StoreOp::Store, }, })]; @@ -4155,11 +4390,20 @@ impl Renderer { ..Default::default() }); pass.set_bind_group(0, &self.group, &[]); - let sky_visible = sky_open(frame); + pack_frame(&mut pass); if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) { - if !frame.title && frame.skybox && !pack_draws_sky { - pass.set_pipeline(&self.sky); - pass.draw(0..3, 0..1); + if !frame.title && frame.skybox && !pack_draws_sky && !native_sky_pass { + match self + .native + .as_ref() + .and_then(|n| Some((n.sky.as_ref()?, &n.frame))) + { + Some((sky, native)) => sky.draw(&mut pass, native), + None => { + pass.set_pipeline(&self.sky); + pass.draw(0..3, 0..1); + } + } } if frame.title { self.panorama.draw(&mut pass); @@ -4195,7 +4439,7 @@ impl Renderer { pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); } Some(flat) => { - pass.set_pipeline(&flat.solid); + pass.set_pipeline(pack_flat.map_or(&flat.solid, |p| &p.0)); let vertices = |p| self.arena.buffer(p); match cull { // As below: phase 1, the test, then phase 2's draws @@ -4213,7 +4457,8 @@ impl Renderer { pack_reads_world, ); pass.set_bind_group(0, &self.group, &[]); - pass.set_pipeline(&flat.solid); + pack_frame(&mut pass); + pass.set_pipeline(pack_flat.map_or(&flat.solid, |p| &p.0)); flat.draw_phase2(&mut pass, vertices); } None => flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices), @@ -4222,7 +4467,7 @@ impl Renderer { pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); } None => { - pass.set_pipeline(&self.solid); + pass.set_pipeline(solid_pipeline); pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); pass.set_vertex_buffer(1, self.instances.slice(..)); match cull { @@ -4242,7 +4487,8 @@ impl Renderer { pack_reads_world, ); pass.set_bind_group(0, &self.group, &[]); - pass.set_pipeline(&self.solid); + pack_frame(&mut pass); + pass.set_pipeline(solid_pipeline); pass.set_index_buffer( self.indices.slice(..), wgpu::IndexFormat::Uint32, @@ -4334,7 +4580,7 @@ impl Renderer { // The pack draws translucent terrain after its deferred programs. _ if pack_draws_water => {} Some(flat) => { - pass.set_pipeline(&flat.translucent); + pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1)); flat.draw(&mut pass, flat.translucent_calls(), |p| { self.arena.buffer(p) }); @@ -4342,7 +4588,7 @@ impl Renderer { pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); } None => { - pass.set_pipeline(&self.translucent); + pass.set_pipeline(translucent_pipeline); pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); pass.set_vertex_buffer(1, self.instances.slice(..)); self.draw_sections( @@ -4511,21 +4757,24 @@ impl Renderer { self.pack_post = Some((dimension, Some(post))); } else if let Some((color, depth)) = &scaled && native_post - && let Some(post) = &mut self.native_post + && let Some(native) = &mut self.native + && let Some(post) = &mut native.post { let size = self.world_post.size().unwrap_or([1, 1]); post.run( &self.device, - &self.queue, &mut encoder, (color, depth), &view_target, - frame, size, + native.timing.as_mut(), ); } else if scaled.is_some() { self.world_post.finish(&mut encoder, &view_target); } + if let Some(hdr) = &self.hdr { + hdr.keep_world(&mut encoder); + } if hand_quads > 0 && !pack_draws_hand { let attachments = [Some(wgpu::RenderPassColorAttachment { view: &view_target, @@ -4680,6 +4929,9 @@ impl Renderer { self.section_copy_bytes = 0; self.section_uploads.finish(&encoder); } + if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) { + hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title); + } let commands = crate::spans::time("encode", || encoder.finish()); let first_frame = self.first_frame == FirstFrame::Submit; if first_frame { @@ -4694,7 +4946,7 @@ impl Renderer { timer.submitted(&self.queue); timer.collect(&self.device, &mut self.gpu_times); } - if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native_post) { + if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) { timer.submitted(&self.queue); } if let Some((path, buffer)) = readback { @@ -4716,7 +4968,12 @@ impl Renderer { } self.queue.present(surface_frame); if reconfigure && let Some(surface) = &self.surface { - surface.configure(&self.device, &self.config); + Self::configure( + &self.device, + surface, + &self.surface_config(), + self.hdr.as_ref(), + ); } } else { // No swapchain paces offscreen frames: keep two on the GPU at most. diff --git a/crates/horizon-client/src/gfx/arena.rs b/crates/horizon-client/src/gfx/arena.rs index 34b4d332d..8f320a408 100644 --- a/crates/horizon-client/src/gfx/arena.rs +++ b/crates/horizon-client/src/gfx/arena.rs @@ -39,6 +39,35 @@ impl FreeList { } self.0.insert(start, len); } + /// `give` for many ranges: one merge of the sorted ranges into the list, + /// where giving them one by one costs a few tree lookups each. + fn give_all(&mut self, mut ranges: Vec<(u64, u64)>) { + if ranges.len() < 64 { + for (start, len) in ranges { + self.give(start, len); + } + return; + } + ranges.sort_unstable(); + let mut free = std::mem::take(&mut self.0).into_iter().peekable(); + let mut given = ranges.into_iter().peekable(); + let mut merged: Vec<(u64, u64)> = Vec::new(); + loop { + let next = match (free.peek(), given.peek()) { + (Some(a), Some(b)) if a.0 <= b.0 => free.next(), + (_, Some(_)) => given.next(), + (Some(_), None) => free.next(), + (None, None) => break, + }; + let (start, len) = next.expect("peeked"); + match merged.last_mut() { + Some(last) if last.0 + last.1 == start => last.1 += len, + _ => merged.push((start, len)), + } + } + // Sorted: the tree is built in one pass. + self.0 = merged.into_iter().collect(); + } } /// One vertex buffer of the arena; only the newest page grows. @@ -237,6 +266,26 @@ impl Arena { .free .give(start, len); } + /// `release` for many ranges (page, start, vertices) at once. + pub fn release_all(&mut self, ranges: impl IntoIterator) { + let mut by_page: Vec> = Vec::new(); + for (page, start, vertices) in ranges { + let page = page as usize; + if by_page.len() <= page { + by_page.resize_with(page + 1, Vec::new); + } + by_page[page].push((start, Self::span(vertices).min(self.page_limit))); + } + for (page, ranges) in by_page.into_iter().enumerate() { + if !ranges.is_empty() { + self.pages[page] + .as_mut() + .expect("live arena page") + .free + .give_all(ranges); + } + } + } /// Moves a page into a larger buffer; false (the page unchanged) when /// there is no memory for it. fn grow( @@ -435,6 +484,44 @@ mod tests { assert_eq!(free, FreeList::new(4 * GRAIN)); } + #[test] + fn many_ranges_given_at_once_coalesce_as_one_by_one() { + let fill = || { + let mut free = FreeList::new(1 << 30); + let taken: Vec<(u64, u64)> = (0..500u64) + .map(|i| { + let len = GRAIN * (1 + i * 7 % 5); + (free.take(len).unwrap(), len) + }) + .collect(); + for &(start, len) in taken.iter().step_by(3) { + free.give(start, len); + } + (free, taken) + }; + // Every other range of those still taken, in a scattered order. + let (mut one_by_one, taken) = fill(); + let (mut at_once, _) = fill(); + let mut given: Vec<(u64, u64)> = (0..taken.len()) + .filter(|i| i % 3 != 0 && i % 2 == 0) + .map(|i| taken[i]) + .collect(); + given.sort_by_key(|&(start, _)| start.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + assert!(given.len() >= 64); + for &(start, len) in &given { + one_by_one.give(start, len); + } + at_once.give_all(given); + assert_eq!(at_once, one_by_one); + // The rest too: everything is free again, in one range. + let rest: Vec<(u64, u64)> = (0..taken.len()) + .filter(|i| i % 3 != 0 && i % 2 != 0) + .map(|i| taken[i]) + .collect(); + at_once.give_all(rest); + assert_eq!(at_once, FreeList::new(1 << 30)); + } + #[test] fn first_fit_reuses_the_lowest_hole() { let mut free = FreeList::new(8 * GRAIN); diff --git a/crates/horizon-client/src/gfx/crumbling_tests.rs b/crates/horizon-client/src/gfx/crumbling_tests.rs new file mode 100644 index 000000000..620a57cf9 --- /dev/null +++ b/crates/horizon-client/src/gfx/crumbling_tests.rs @@ -0,0 +1,191 @@ +use wgpu::util::DeviceExt; + +/// Sample the production WGSL with the production nearest/no-mipmap atlas sampler. +#[test] +#[ignore = "requires a Vulkan/Metal/DX12 adapter"] +fn crack_samples_stay_aligned_between_stages() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .unwrap(); + eprintln!("crack sampling adapter: {:?}", adapter.get_info()); + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).unwrap(); + #[repr(C)] + #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] + struct Case { + uv: [f32; 2], + stage: u32, + extent: u32, + } + let mut cases = Vec::new(); + let mut expected = Vec::<[u32; 2]>::new(); + // Include exact texel boundaries, their neighbours, negative projected UVs, + // wrapping and the far sprite edge. Origins straddle the packing error's sign change. + for extent in [16, 32] { + for x in [32, 2080, 4064] { + for y in [0, 32] { + for i in -32..=32 { + for delta in [-0.0001, 0.0, 0.0001] { + let uv = [i as f32 / 16.0 + delta, 0.5 - delta]; + cases.push(Case { + uv, + stage: crate::breaking::stage_light([ + x as f32 / 4096.0, + y as f32 / 64.0, + ]), + extent, + }); + expected.push(uv.map(|v| { + (((v - v.floor()) * extent as f32).floor() as u32).min(extent - 1) + })); + } + } + } + } + } + let math = include_str!("../entity.wgsl") + .split("// CRUMBLING_UV_BEGIN") + .nth(1) + .unwrap() + .split("// CRUMBLING_UV_END") + .next() + .unwrap(); + let source = format!( + "{math}\n{}", + r#" +struct Case { uv: vec2, stage: u32, extent: u32 } +@group(0) @binding(0) var cases: array; +@group(0) @binding(1) var results: array>; +@group(0) @binding(2) var atlas: texture_2d; +@group(0) @binding(3) var atlas_sampler: sampler; +@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3) { + if id.x >= arrayLength(&cases) { return; } + let c = cases[id.x]; + let origin = vec2(f32(c.stage & 65535u), f32(c.stage >> 16u)) / 65535.0; + let dimensions = textureDimensions(atlas); + let uv = crumbling_uv(c.uv, origin, vec2(f32(c.extent)) / vec2(dimensions), dimensions); + // Keep the old addressing as a control: the fixture must reproduce its stage jitter. + let old_uv = origin + fract(c.uv) * vec2(f32(c.extent)) / vec2(dimensions); + results[id.x] = vec4( + vec2(round(textureSampleLevel(atlas, atlas_sampler, uv, 0.0).rg * 255.0)), + vec2(round(textureSampleLevel(atlas, atlas_sampler, old_uv, 0.0).rg * 255.0))); +}"# + ); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Crack sampling regression"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: None, + layout: None, + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + // Every sprite repeats the same coordinate pattern, so moving a stage's + // atlas origin must leave every sampled local texel unchanged. + let pixels: Vec = (0..64) + .flat_map(|y| (0..4096).flat_map(move |x| [(x % 32) as u8, (y % 32) as u8, 0, 255])) + .collect(); + let atlas = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 4096, + height: 64, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &pixels, + ); + let view = atlas.create_view(&Default::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); + let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: None, + contents: bytemuck::cast_slice(&cases), + usage: wgpu::BufferUsages::STORAGE, + }); + let size = (expected.len() * 16) as u64; + let output = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: input.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: output.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_compute_pass(&Default::default()); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &group, &[]); + pass.dispatch_workgroups((cases.len() as u32).div_ceil(64), 1, 1); + } + encoder.copy_buffer_to_buffer(&output, 0, &readback, 0, size); + queue.submit([encoder.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + readback.map_async(wgpu::MapMode::Read, .., move |r| tx.send(r).unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + rx.recv().unwrap().unwrap(); + let mapped = readback.get_mapped_range(..).unwrap(); + let actual: &[[u32; 4]] = bytemuck::cast_slice(&mapped); + for (i, (actual, expected)) in actual.iter().zip(&expected).enumerate() { + assert_eq!( + &actual[..2], + expected, + "case {i}, uv={:?}, stage={}, extent={}", + cases[i].uv, + cases[i].stage, + cases[i].extent + ); + } + let old_mismatches = actual + .iter() + .zip(&expected) + .filter(|(a, e)| &a[2..] != e.as_slice()) + .count(); + assert!( + old_mismatches > 0, + "fixture must detect the original sampling bug" + ); + eprintln!( + "{} crack texture samples matched; old addressing mismatched {old_mismatches}", + cases.len() + ); +} diff --git a/crates/horizon-client/src/gfx/hdr.rs b/crates/horizon-client/src/gfx/hdr.rs new file mode 100644 index 000000000..11deed546 --- /dev/null +++ b/crates/horizon-client/src/gfx/hdr.rs @@ -0,0 +1,222 @@ +//! HDR output (Horizon setting, off by default): the frame renders exactly as +//! without it, into an 8-bit target in vanilla's gamma space, and one pass +//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white, +//! expanding the world's highlights towards their peak brightness. The world +//! is copied aside before the hand and HUD so only it is expanded. + +/// The swapchain format and colour space HDR output presents in. +pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; +pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear; + +/// Whether `caps` can present scRGB. +pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool { + caps.color_spaces(FORMAT) + .contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR) +} + +struct Targets { + size: [u32; 2], + frame: wgpu::Texture, + world: wgpu::Texture, + group: wgpu::BindGroup, +} + +pub struct Hdr { + layout: wgpu::BindGroupLayout, + pipeline: wgpu::RenderPipeline, + params: wgpu::Buffer, + /// The frame's format: what every other pipeline draws in. + format: wgpu::TextureFormat, + targets: Option, + /// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta. + debug: bool, + /// Paper white and peak brightness, in nits. + pub paper_white: f32, + pub peak: f32, +} + +impl Hdr { + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Horizon HDR"), + source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()), + }); + let texture = |binding| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Horizon HDR bindings"), + entries: &[ + texture(0), + texture(1), + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Horizon HDR"), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Horizon HDR"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_hdr"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_hdr"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + let params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Horizon HDR parameters"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + Self { + layout, + pipeline, + params, + format, + targets: None, + debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"), + paper_white: 200.0, + peak: 1000.0, + } + } + + /// The 8-bit frame every pass draws into this frame, sized like the window. + pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture { + if self.targets.as_ref().is_none_or(|t| t.size != size) { + let texture = |label, usage| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: size[0].max(1), + height: size[1].max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.format, + usage: usage | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + }; + let frame = texture( + "Horizon HDR frame", + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + ); + let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST); + let view = |t: &wgpu::Texture| t.create_view(&Default::default()); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Horizon HDR bindings"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view(&frame)), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view(&world)), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.params.as_entire_binding(), + }, + ], + }); + self.targets = Some(Targets { + size, + frame, + world, + group, + }); + } + self.targets.as_ref().unwrap().frame.clone() + } + + /// Keeps the world as drawn so far, before the hand and HUD cover it. + pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) { + let Some(t) = &self.targets else { + return; + }; + encoder.copy_texture_to_texture( + t.frame.as_image_copy(), + t.world.as_image_copy(), + t.frame.size(), + ); + } + + /// Writes the finished frame to the swapchain image `target`. + pub fn present( + &self, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::Texture, + world: bool, + ) { + let Some(t) = &self.targets else { + return; + }; + let paper = self.paper_white.max(1.0); + let params = [ + paper / 80.0, + (self.peak / paper).max(1.0), + f32::from(u8::from(world)), + f32::from(u8::from(self.debug)), + ]; + queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms)); + let view = target.create_view(&Default::default()); + let attachments = [Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })]; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Horizon HDR"), + color_attachments: &attachments, + ..Default::default() + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &t.group, &[]); + pass.draw(0..3, 0..1); + } +} diff --git a/crates/horizon-client/src/gfx/native_material.rs b/crates/horizon-client/src/gfx/native_material.rs new file mode 100644 index 000000000..e7f8f7bf2 --- /dev/null +++ b/crates/horizon-client/src/gfx/native_material.rs @@ -0,0 +1,328 @@ +//! The material stage of Horizon's own shader packs (docs/shaders.md): the pack's `material` +//! function gives the lit colour of each point of a block's surface, before the alpha test and +//! fog. Terrain still draws with Horizon's meshes, culling and draw lists; only its pipelines +//! change. Their shader is the pack's stage (`CheckedStage::source`), then a copy of +//! terrain.wgsl's terrain section whose names all take a `horizon_terrain_` prefix +//! (`wgsl_prefix.rs`), then entries that hand the pack what vanilla's `fs_solid` and +//! `fs_translucent` combine: the texture, the vertex tint, the light levels and the lightmap's +//! colour. They read the terrain's group 0, the flat draws' group 1 where those run, and the +//! pack's frame at group 3. + +use horizon_shader_api::{check::CheckedStage, stages::GROUP}; + +use super::native_post::{NativeFrame, present_constants}; +use super::wgsl_prefix::{TERRAIN, prefixed, section}; + +const PREFIX: &str = "horizon_terrain_"; + +/// The entries around the pack's `material`: vanilla's vertex work without the lightmap +/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do. +const ENTRIES: &str = " +struct HorizonMaterialOut { + @builtin(position) clip: vec4, + @location(0) uv: vec2, + @location(1) @interpolate(flat) layer: u32, + @location(2) tint: vec4, + @location(3) light: vec2, + @location(4) lightmap: vec3, + @location(5) position: vec3, + @location(6) spherical: f32, + @location(7) cylindrical: f32, +} +fn horizon_material_vertex(position: vec3, uv: vec2, layer: u32, color: vec4, + light: vec2, origin: vec3) -> HorizonMaterialOut { + let pos = position + origin; + var out: HorizonMaterialOut; + out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0); + out.uv = uv; + out.layer = layer; + out.tint = vec4(color.rgb, 1.0); + let block = max(f32(light.y), horizon_terrain_dynamic_block_light(pos)); + let sky = f32(light.x); + // 16 per level, 15 levels. + out.light = min(vec2(block, sky) / 240.0, vec2(1.0)); + out.lightmap = horizon_terrain_sample_lightmap(sky / 16.0, block / 16.0).rgb; + out.position = pos; + out.spherical = length(pos); + out.cylindrical = max(length(pos.xz), abs(pos.y)); + return out; +} +@vertex fn horizon_material_vs( + @location(0) position: vec3, @location(1) uv: vec2, + @location(2) layer: u32, @location(3) color: vec4, @location(4) light: vec2, + @location(5) origin: vec4) -> HorizonMaterialOut { + return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer, + color, light, origin.xyz); +} +@vertex fn horizon_material_vs_flat( + @builtin(vertex_index) vertex: u32, + @location(0) position: vec3, @location(1) uv: vec2, + @location(2) layer: u32, @location(3) color: vec4, @location(4) light: vec2 +) -> HorizonMaterialOut { + let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[vertex >> 8u]]; + return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer, + color, light, origin.xyz); +} +fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4 { + let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu); + return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv, + translucent)).color; +} +@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4 { + let color = horizon_material_color(in, false); + // Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout. + if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } + return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical); +} +@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4 { + let color = horizon_material_color(in, true); + if color.a < 0.01 { discard; } + return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical); +} +"; + +/// What terrain pipelines are built against: the renderer's layouts and constants. +pub struct TerrainShape<'a> { + /// The terrain's group 0. + pub globals: &'a wgpu::BindGroupLayout, + /// The flat draws' group 1, where they run (`flat.rs`). + pub flat: Option<&'a wgpu::BindGroupLayout>, + /// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`. + pub sprites: [f64; 2], + /// The world target's colour format. + pub format: wgpu::TextureFormat, +} + +/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the +/// renderer's instanced draws and, where they run, its flat ones. +pub struct NativeMaterial { + pub solid: wgpu::RenderPipeline, + pub translucent: wgpu::RenderPipeline, + pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, +} + +/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with +/// every name it declares prefixed. +fn vanilla_terrain() -> String { + prefixed( + section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"), + PREFIX, + ) +} + +/// The terrain shader for the pack's material stage `source`. +fn shader_source(source: &str) -> String { + format!( + "{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}", + vanilla_terrain() + ) +} + +/// The material's naga module, its errors in the stage's file. +fn module(stage: &CheckedStage) -> Result { + let text = shader_source(&stage.source); + naga::front::wgsl::parse_str(&text) + .map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text))) +} + +impl NativeMaterial { + /// The pipelines of the pack's material `stage` with the option values `constants`. + pub fn new( + device: &wgpu::Device, + shape: &TerrainShape<'_>, + frame: &NativeFrame, + stage: &CheckedStage, + constants: &[(String, f64)], + ) -> Result { + let module = module(stage)?; + let mut constants = present_constants(&module, constants); + let sprite_names = [ + "horizon_terrain_SPRITE_ARRAYS", + "horizon_terrain_SPRITE_SPLIT", + ]; + constants.extend(sprite_names.into_iter().zip(shape.sprites)); + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&stage.label), + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)), + }); + let layout = |flat: Option<&wgpu::BindGroupLayout>| { + let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = + Default::default(); + groups[0] = Some(shape.globals); + groups[1] = flat; + groups[GROUP as usize] = Some(&frame.layout); + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Native shader pack terrain"), + bind_group_layouts: &groups, + ..Default::default() + }) + }; + let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4]; + let vertices = wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &super::TERRAIN_ATTRIBUTES, + }; + let instances = wgpu::VertexBufferLayout { + array_stride: 16, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &instance_attributes, + }; + // As the renderer's terrain pipelines (gfx.rs): back faces culled, reversed depth. + let pipeline = |layout: &wgpu::PipelineLayout, + vertex: &str, + buffers: &[Option>], + fragment: &str, + blend: Option| { + let options = wgpu::PipelineCompilationOptions { + constants: &constants, + ..Default::default() + }; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(&stage.label), + layout: Some(layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some(vertex), + compilation_options: options.clone(), + buffers, + }, + primitive: wgpu::PrimitiveState { + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::GreaterEqual), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(fragment), + compilation_options: options, + targets: &[Some(wgpu::ColorTargetState { + format: shape.format, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) + }; + let blend = Some(wgpu::BlendState::ALPHA_BLENDING); + let instanced = layout(None); + let buffers = [Some(vertices.clone()), Some(instances)]; + let solid = pipeline( + &instanced, + "horizon_material_vs", + &buffers, + "horizon_material_fs_solid", + None, + ); + let translucent = pipeline( + &instanced, + "horizon_material_vs", + &buffers, + "horizon_material_fs_translucent", + blend, + ); + let flat = shape.flat.map(|group| { + let layout = layout(Some(group)); + let buffers = [Some(vertices.clone())]; + let vs = "horizon_material_vs_flat"; + ( + pipeline(&layout, vs, &buffers, "horizon_material_fs_solid", None), + pipeline( + &layout, + vs, + &buffers, + "horizon_material_fs_translucent", + blend, + ), + ) + }); + if let Some(e) = pollster::block_on(scope.pop()) { + return Err(format!("{}: {e}", stage.file)); + } + Ok(Self { + solid, + translucent, + flat, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_shader_api::{MANIFEST, Pack, StageKind, check}; + + fn stage(material: &str) -> CheckedStage { + let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\ + [material]\nfile = \"material.wgsl\"\n\ + [[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\n\ + default = 1.0\nmin = 0.0\nmax = 2.0\n"; + let files = [(MANIFEST, manifest), ("material.wgsl", material)] + .map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec())) + .into(); + let mut report = check(&Pack::from_files(files).unwrap()).unwrap(); + assert_eq!(report.stages[0].kind, StageKind::Material); + report.stages.remove(0) + } + + #[test] + fn the_terrain_takes_a_prefix_and_validates_with_a_pack() { + let terrain = vanilla_terrain(); + for name in [ + "Globals", + "globals", + "sprites_7", + "sprite", + "SPRITE_SPLIT", + "block_slots", + "sample_lightmap", + "apply_fog", + ] { + assert!(terrain.contains(&format!("{PREFIX}{name}")), "{name}"); + } + assert!(!terrain.contains("fs_sky"), "the sky stays out"); + // A pack may use any name Horizon's terrain uses unprefixed. + let own = "override gain: f32;\nconst PI: f32 = 3.0;\n\ + fn sprite(c: vec4) -> vec4 { return c * PI; }\n\ + fn apply_fog(c: vec4) -> vec4 { return c * gain; }\n\ + fn material(input: MaterialInput) -> Surface {\n \ + let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \ + return Surface(apply_fog(sprite(lit)));\n}\n"; + let stage = stage(own); + let module = module(&stage).unwrap(); + let info = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source)))); + let entries: Vec<&str> = module + .entry_points + .iter() + .map(|e| e.name.as_str()) + .collect(); + for entry in [ + "horizon_material_vs", + "horizon_material_vs_flat", + "horizon_material_fs_solid", + "horizon_material_fs_translucent", + ] { + assert!(entries.contains(&entry), "{entry}"); + } + drop(info); + assert_eq!( + present_constants(&module, &[("gain".into(), 1.5), ("other".into(), 1.0)]), + [("gain", 1.5)] + ); + } +} diff --git a/crates/horizon-client/src/gfx/native_pack.rs b/crates/horizon-client/src/gfx/native_pack.rs new file mode 100644 index 000000000..4fb5cdc59 --- /dev/null +++ b/crates/horizon-client/src/gfx/native_pack.rs @@ -0,0 +1,74 @@ +//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages +//! read, its sky, material and post stages, and the timer of their passes. + +use horizon_shader_api::{Report, StageKind}; + +use super::native_material::{NativeMaterial, TerrainShape}; +use super::native_post::{NativeFrame, NativePost}; +use super::native_sky::NativeSky; +use super::pack_timing::PassTiming; + +pub struct NativePack { + pub frame: NativeFrame, + pub sky: Option, + pub material: Option, + pub post: Option, + /// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`). + pub timing: Option, +} + +impl NativePack { + /// The pack of the checked stages `report` with the option values `constants`, drawing + /// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0. + /// Also returns its stages' labels, in the order they draw. Errors name the stage wgpu + /// refused. + pub fn new( + device: &wgpu::Device, + terrain: &TerrainShape<'_>, + report: Report, + constants: &[(String, f64)], + ) -> Result<(Self, Vec), String> { + let (globals, format) = (terrain.globals, terrain.format); + let frame = NativeFrame::new(device); + let (post, others): (Vec<_>, Vec<_>) = report + .stages + .into_iter() + .partition(|s| s.kind == StageKind::Post); + let sky = others + .iter() + .find(|s| s.kind == StageKind::Sky) + .map(|stage| NativeSky::new(device, globals, &frame, stage, constants, format)) + .transpose()?; + let material = others + .iter() + .find(|s| s.kind == StageKind::Material) + .map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants)) + .transpose()?; + let post = (!post.is_empty()) + .then(|| NativePost::new(device, post, constants, format, &frame)) + .transpose()?; + let mut labels: Vec = sky.iter().map(|s| s.label.clone()).collect(); + labels.extend( + others + .iter() + .filter(|s| material.is_some() && s.kind == StageKind::Material) + .map(|s| s.label.clone()), + ); + if let Some(post) = &post { + labels.extend(post.labels().into_iter().map(str::to_owned)); + } + let pack = Self { + frame, + sky, + material, + post, + timing: None, + }; + Ok((pack, labels)) + } + + /// Whether the pack draws anything the game runs today. + pub fn draws(&self) -> bool { + self.sky.is_some() || self.material.is_some() || self.post.is_some() + } +} diff --git a/crates/horizon-client/src/gfx/native_post.rs b/crates/horizon-client/src/gfx/native_post.rs index cf99b1936..89515f5db 100644 --- a/crates/horizon-client/src/gfx/native_post.rs +++ b/crates/horizon-client/src/gfx/native_post.rs @@ -3,7 +3,8 @@ //! the pack's options as pipeline constants. A pass reads bind group 3: the frame, the world's //! colour and depth, the previous pass's output and two samplers. Passes before the last write //! half-float targets of the world's size, so values over 1 carry to the next; the last draws -//! onto the screen. +//! onto the screen. Every stage of a pack reads the same `frame`, written once a frame +//! (`NativeFrame`). use glam::{Mat4, Vec3}; use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP}; @@ -25,12 +26,87 @@ pub struct NativePost { targets: Option<([u32; 2], [wgpu::TextureView; 2])>, /// One bind group per pass, for these world views. groups: Option<(WorldViews, Vec)>, - start: std::time::Instant, - frames: u32, /// The screen's colour format, the world target's too. format: wgpu::TextureFormat, - /// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`). - pub timing: Option, +} + +/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame. +pub struct NativeFrame { + pub(super) buffer: wgpu::Buffer, + /// Group 3 of the stages that read only the frame (the sky). + pub layout: wgpu::BindGroupLayout, + pub group: wgpu::BindGroup, + start: std::time::Instant, + frames: u32, +} + +/// The uniform `frame` at binding 0, read by `visibility`. +fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(size_of::() as u64), + }, + count: None, + } +} + +impl NativeFrame { + pub fn new(device: &wgpu::Device) -> Self { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Native shader pack frame"), + size: size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Native shader pack frame"), + entries: &[frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT)], + }); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Native shader pack frame"), + layout: &layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer.as_entire_binding(), + }], + }); + Self { + buffer, + layout, + group, + start: std::time::Instant::now(), + frames: 0, + } + } + + /// Writes this frame's inputs, for a world of `size`. + pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) { + let seconds = self.start.elapsed().as_secs_f32() % 3600.0; + let inputs = frame_inputs(frame, size, seconds, self.frames); + self.frames = self.frames.wrapping_add(1); + queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs)); + } +} + +/// The option values of `constants` that `module` declares as overrides (wgpu refuses others). +pub fn present_constants<'a>( + module: &naga::Module, + constants: &'a [(String, f64)], +) -> Vec<(&'a str, f64)> { + constants + .iter() + .filter(|(id, _)| { + module + .overrides + .iter() + .any(|(_, o)| o.name.as_ref() == Some(id)) + }) + .map(|(id, v)| (id.as_str(), *v)) + .collect() } fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry { @@ -63,21 +139,13 @@ impl NativePost { stages: Vec, constants: &[(String, f64)], format: wgpu::TextureFormat, + frame: &NativeFrame, ) -> Result { let float = wgpu::TextureSampleType::Float { filterable: true }; let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Native shader pack post"), entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: wgpu::BufferSize::new(size_of::() as u64), - }, - count: None, - }, + frame_entry(wgpu::ShaderStages::FRAGMENT), texture_entry(1, float), texture_entry(2, wgpu::TextureSampleType::Depth), texture_entry(3, float), @@ -95,17 +163,7 @@ impl NativePost { let count = stages.len(); let mut passes = Vec::with_capacity(count); for (i, stage) in stages.into_iter().enumerate() { - let present: Vec<(&str, f64)> = constants - .iter() - .filter(|(id, _)| { - stage - .module - .overrides - .iter() - .any(|(_, o)| o.name.as_ref() == Some(id)) - }) - .map(|(id, v)| (id.as_str(), *v)) - .collect(); + let present = present_constants(&stage.module, constants); let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some(&stage.label), @@ -145,12 +203,6 @@ impl NativePost { } passes.push((stage.label, pipeline)); } - let frame = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Native shader pack frame"), - size: size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); let sampler = |label, filter| { device.create_sampler(&wgpu::SamplerDescriptor { label: Some(label), @@ -162,15 +214,12 @@ impl NativePost { Ok(Self { passes, layout, - frame, + frame: frame.buffer.clone(), linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear), nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest), targets: None, groups: None, - start: std::time::Instant::now(), - frames: 0, format, - timing: None, }) } @@ -264,37 +313,18 @@ impl NativePost { } /// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto - /// `screen`. + /// `screen`, timed by `timing` when it times this frame. pub fn run( &mut self, device: &wgpu::Device, - queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, world: (&wgpu::TextureView, &wgpu::TextureView), screen: &wgpu::TextureView, - frame: &Frame, - size: [u32; 2], - ) { - let seconds = self.start.elapsed().as_secs_f32() % 3600.0; - let inputs = frame_inputs(frame, size, seconds, self.frames); - self.frames = self.frames.wrapping_add(1); - self.draw(device, queue, encoder, world, screen, &inputs, size); - } - - fn draw( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder, - world: (&wgpu::TextureView, &wgpu::TextureView), - screen: &wgpu::TextureView, - inputs: &FrameInputs, size: [u32; 2], + mut timing: Option<&mut super::pack_timing::PassTiming>, ) { self.prepare(device, world, size); - queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(inputs)); - if let Some(t) = &mut self.timing { - t.begin(device, queue); + if let Some(t) = timing.as_deref_mut() { t.mark(device, encoder, (world.0, self.format)); } let Some((_, groups)) = &self.groups else { @@ -317,7 +347,7 @@ impl NativePost { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some(label), color_attachments: &attachments, - timestamp_writes: self.timing.as_mut().and_then(|t| t.writes(label)), + timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)), ..Default::default() }); pass.set_pipeline(pipeline); @@ -437,8 +467,20 @@ mod tests { .into(); let report = check(&Pack::from_files(files).unwrap()).unwrap(); let format = wgpu::TextureFormat::Rgba8Unorm; - let mut post = - NativePost::new(&device, report.stages, &[("gain".into(), 1.0)], format).unwrap(); + let frame = NativeFrame::new(&device); + queue.write_buffer( + &frame.buffer, + 0, + bytemuck::bytes_of(&FrameInputs::default()), + ); + let mut post = NativePost::new( + &device, + report.stages, + &[("gain".into(), 1.0)], + format, + &frame, + ) + .unwrap(); assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]); let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; @@ -478,15 +520,7 @@ mod tests { ..Default::default() }); let view = screen.create_view(&Default::default()); - post.draw( - &device, - &queue, - &mut encoder, - (&color, &depth), - &view, - &FrameInputs::default(), - [4, 4], - ); + post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None); let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: None, size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4), diff --git a/crates/horizon-client/src/gfx/native_sky.rs b/crates/horizon-client/src/gfx/native_sky.rs new file mode 100644 index 000000000..3b3c9a3b7 --- /dev/null +++ b/crates/horizon-client/src/gfx/native_sky.rs @@ -0,0 +1,379 @@ +//! The sky stage of Horizon's own shader packs (docs/shaders.md): the pack's `sky` function, +//! called for each pixel of the sky disc with vanilla's colour along its view ray. The +//! pipeline's shader is the pack's stage (`CheckedStage::source`) followed by a copy of the +//! renderer's sky (terrain.wgsl's `Globals`, `linear_fog` and sky section) whose names all +//! take a `horizon_sky_` prefix, so none can meet a pack's (the check refuses `horizon_` +//! names). It reads the renderer's globals (group 0) and the pack's frame (group 3), and +//! draws where vanilla's sky draws; the sun, moon, stars and the dark disc are drawn over it. + +use horizon_shader_api::{check::CheckedStage, stages::GROUP}; + +use super::native_post::{NativeFrame, present_constants}; +use super::wgsl_prefix::{TERRAIN, prefixed, section}; + +const PREFIX: &str = "horizon_sky_"; + +/// The fragment entry that calls the pack: vanilla's colour in, an opaque colour out. +const PACK_ENTRY: &str = " +@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4 { + let d = horizon_sky_view_ray(in.ndc); + return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0); +} +"; + +pub struct NativeSky { + pipeline: wgpu::RenderPipeline, + pub label: String, +} + +/// terrain.wgsl's sky with every name it declares (and `globals`) prefixed. +fn vanilla_sky() -> String { + let text = [ + section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"), + section(TERRAIN, "fn linear_fog(", "fn apply_fog("), + section(TERRAIN, "struct SkyOut {", "// rendertype_lines"), + ] + .concat(); + prefixed(&text, PREFIX) +} + +/// The sky shader for the pack's sky stage `source`. +fn shader_source(source: &str) -> String { + format!( + "{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}", + vanilla_sky() + ) +} + +/// A sky pipeline over `module` whose fragment entry is `fragment`. +fn pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + module: &wgpu::ShaderModule, + fragment: &str, + constants: &[(&str, f64)], + format: wgpu::TextureFormat, +) -> wgpu::RenderPipeline { + let options = wgpu::PipelineCompilationOptions { + constants, + ..Default::default() + }; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Native shader pack sky"), + layout: Some(layout), + vertex: wgpu::VertexState { + module, + entry_point: Some("horizon_sky_vs_sky"), + compilation_options: options.clone(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(fragment), + compilation_options: options, + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) +} + +/// The sky's naga module and the layout of its pipelines: `globals` at group 0, the frame +/// at group 3. +fn build( + device: &wgpu::Device, + globals: &wgpu::BindGroupLayout, + frame: &NativeFrame, + stage: &CheckedStage, +) -> Result<(naga::Module, wgpu::PipelineLayout), String> { + let text = shader_source(&stage.source); + let module = naga::front::wgsl::parse_str(&text) + .map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?; + let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default(); + groups[0] = Some(globals); + groups[GROUP as usize] = Some(&frame.layout); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Native shader pack sky"), + bind_group_layouts: &groups, + ..Default::default() + }); + Ok((module, layout)) +} + +impl NativeSky { + /// The pipeline of the pack's sky `stage` with the option values `constants`, drawing + /// onto the world's target of `format`; `globals` is the terrain's group 0. + pub fn new( + device: &wgpu::Device, + globals: &wgpu::BindGroupLayout, + frame: &NativeFrame, + stage: &CheckedStage, + constants: &[(String, f64)], + format: wgpu::TextureFormat, + ) -> Result { + let (module, layout) = build(device, globals, frame, stage)?; + let present = present_constants(&module, constants); + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&stage.label), + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)), + }); + let pipeline = pipeline( + device, + &layout, + &module, + "horizon_sky_fs_pack", + &present, + format, + ); + if let Some(e) = pollster::block_on(scope.pop()) { + return Err(format!("{}: {e}", stage.file)); + } + Ok(Self { + pipeline, + label: stage.label.clone(), + }) + } + + /// Draws the sky in a pass whose group 0 is the terrain's. + pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, frame: &NativeFrame) { + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(GROUP, &frame.group, &[]); + pass.draw(0..3, 0..1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_shader_api::{Frame as FrameInputs, MANIFEST, Pack, StageKind, check}; + + fn stage(sky: &str) -> CheckedStage { + let mut manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\ + [sky]\nfile = \"sky.wgsl\"\n" + .to_owned(); + if sky.contains("gain") { + manifest += "[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\n\ + default = 1.0\nmin = 0.0\nmax = 2.0\n"; + } + let manifest = manifest.as_str(); + let files = [(MANIFEST, manifest), ("sky.wgsl", sky)] + .map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec())) + .into(); + let mut report = check(&Pack::from_files(files).unwrap()).unwrap(); + assert_eq!(report.stages[0].kind, StageKind::Sky); + report.stages.remove(0) + } + + #[test] + fn the_renderer_s_sky_takes_a_prefix_and_validates_with_a_pack() { + let sky = vanilla_sky(); + for name in [ + "Globals", + "SkyOut", + "view_ray", + "PI", + "sky_along_ray", + "linear_fog", + "fs_sky", + ] { + assert!(sky.contains(&format!("{PREFIX}{name}")), "{name}"); + } + // A pack may use the renderer's names for its own things. + let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\ + fn sky(input: SkyInput) -> vec4 { return input.color * gain * view_ray(); }\n"; + let text = shader_source(&stage(own).source); + let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| { + panic!("{}", e.emit_to_string(&text)); + }); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::default(), + ) + .validate(&module) + .unwrap(); + } + + /// Vanilla's sky and two packs over the same globals: one that returns its input draws + /// vanilla's pixels, one that inverts them draws the inverse (and stays opaque). + #[test] + fn a_pack_gets_vanilla_s_colour_and_draws_opaque() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else { + return; + }; + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let format = wgpu::TextureFormat::Rgba8Unorm; + let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: None, + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let mut globals: super::super::Globals = bytemuck::Zeroable::zeroed(); + globals.inverse_view = glam::Mat4::IDENTITY.to_cols_array_2d(); + globals.misc = [0.0, 0.0, 1.0, 1.0]; + globals.fog_color = [0.2, 0.4, 0.6, 1.0]; + globals.sky_color = [0.5, 0.7, 1.0, 1.0]; + globals.fog_ends = [64.0, 0.0, 0.0, 0.0]; + let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&globals_buffer, 0, bytemuck::bytes_of(&globals)); + let globals_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &globals_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: globals_buffer.as_entire_binding(), + }], + }); + let frame = NativeFrame::new(&device); + queue.write_buffer( + &frame.buffer, + 0, + bytemuck::bytes_of(&FrameInputs::default()), + ); + + let gain = [("gain".to_owned(), 1.0)]; + let draw = |stage: &CheckedStage, vanilla: bool| -> Vec { + let sky = + NativeSky::new(&device, &globals_layout, &frame, stage, &gain, format).unwrap(); + let pipeline = if vanilla { + let (module, layout) = build(&device, &globals_layout, &frame, stage).unwrap(); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)), + }); + pipeline(&device, &layout, &module, "horizon_sky_fs_sky", &[], format) + } else { + sky.pipeline.clone() + }; + let texture = |format, usage| { + device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 4, + height: 4, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + }; + let color = texture( + format, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + ); + let depth = texture( + wgpu::TextureFormat::Depth32Float, + wgpu::TextureUsages::RENDER_ATTACHMENT, + ); + let (color_view, depth_view) = ( + color.create_view(&Default::default()), + depth.create_view(&Default::default()), + ); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &color_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.0), + store: wgpu::StoreOp::Discard, + }), + stencil_ops: None, + }), + ..Default::default() + }); + pass.set_bind_group(0, &globals_group, &[]); + pass.set_pipeline(&pipeline); + pass.set_bind_group(GROUP, &frame.group, &[]); + pass.draw(0..3, 0..1); + } + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + color.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT), + rows_per_image: Some(4), + }, + }, + color.size(), + ); + queue.submit([encoder.finish()]); + buffer + .slice(..) + .map_async(wgpu::MapMode::Read, |r| r.unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec(); + (0..4) + .flat_map(|row| { + let start = row * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + bytes[start..start + 16].to_vec() + }) + .collect() + }; + let same = stage("fn sky(input: SkyInput) -> vec4 { return input.color; }\n"); + let vanilla = draw(&same, true); + assert_eq!(draw(&same, false), vanilla); + // The top row looks up into the sky disc, the bottom one down into the fog. + assert_ne!(vanilla[..4], vanilla[vanilla.len() - 4..]); + let inverse = stage( + "override gain: f32;\nfn sky(input: SkyInput) -> vec4 { return vec4(vec3(gain) - input.color.rgb, 0.25); }\n", + ); + let inverted = draw(&inverse, false); + for (got, want) in inverted.chunks(4).zip(vanilla.chunks(4)) { + assert_eq!(got[3], 255); + for i in 0..3 { + assert!(got[i].abs_diff(255 - want[i]) <= 1, "{got:?} {want:?}"); + } + } + } +} diff --git a/crates/horizon-client/src/gfx/occlusion.rs b/crates/horizon-client/src/gfx/occlusion.rs index e17ba87d1..22e2f3c81 100644 --- a/crates/horizon-client/src/gfx/occlusion.rs +++ b/crates/horizon-client/src/gfx/occlusion.rs @@ -522,16 +522,27 @@ impl Occlusion { } pub fn set(&mut self, key: SectionKey, vis: u64) { if self.columns.set(key, vis) { - self.edits.push(Edit::Set(key, vis)); - self.dirty = true; - self.changed.push(key); - if self.running.is_some() { - self.changed_late.push(key); - } + self.changed(key, vis); } } - pub fn remove(&mut self, key: SectionKey) { - self.set(key, ALL_VISIBLE); + fn changed(&mut self, key: SectionKey, vis: u64) { + self.edits.push(Edit::Set(key, vis)); + self.dirty = true; + self.changed.push(key); + if self.running.is_some() { + self.changed_late.push(key); + } + } + /// Every section of the column `x`, `z` goes (sees through every face). + pub fn remove_column(&mut self, x: i32, z: i32) { + let Some(column) = self.columns.map.remove(&(x, z)) else { + return; + }; + for (y, &vis) in column.iter().enumerate() { + if vis != ALL_VISIBLE { + self.changed((x, y as i32, z), ALL_VISIBLE); + } + } } pub fn clear(&mut self) { self.columns.map.clear(); diff --git a/crates/horizon-client/src/gfx/pack_atlas.rs b/crates/horizon-client/src/gfx/pack_atlas.rs index 42a2abee1..c7a67a50b 100644 --- a/crates/horizon-client/src/gfx/pack_atlas.rs +++ b/crates/horizon-client/src/gfx/pack_atlas.rs @@ -169,6 +169,7 @@ impl PackAtlas { ]; let columns = self.columns; // UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped. + // The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1. let body = format!( " vec4 hz_origin = {origin};\n \ uint hz_layer = hz_v_layer & 32767u;\n \ @@ -178,7 +179,7 @@ impl PackAtlas { hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \ hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \ hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \ - hz_Color = hz_v_color;\n \ + hz_Color = vec4(hz_v_color.rgb, 1.0);\n \ hz_Normal = vec3(0.0, 1.0, 0.0);\n \ hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n", scale[0], scale[1], diff --git a/crates/horizon-client/src/gfx/pack_timing.rs b/crates/horizon-client/src/gfx/pack_timing.rs index 24b97d7af..2a8dcf4a5 100644 --- a/crates/horizon-client/src/gfx/pack_timing.rs +++ b/crates/horizon-client/src/gfx/pack_timing.rs @@ -128,6 +128,11 @@ impl PassTiming { } } + /// Whether the frame being recorded is timed. + pub fn recording(&self) -> bool { + matches!(self.stage, Stage::Recording) + } + /// Timestamp writes for the pass `name`, in a timed frame. pub fn writes(&mut self, name: &str) -> Option> { let i = self.push(name)?; diff --git a/crates/horizon-client/src/gfx/pack_uniforms.rs b/crates/horizon-client/src/gfx/pack_uniforms.rs index c4df4377b..8f5f1a2de 100644 --- a/crates/horizon-client/src/gfx/pack_uniforms.rs +++ b/crates/horizon-client/src/gfx/pack_uniforms.rs @@ -399,7 +399,8 @@ impl FrameUniforms { _ => Value::i(&[0]), } }; - self.values = NAMES.iter().map(|n| value(n)).collect(); + self.values.clear(); + self.values.extend(NAMES.iter().map(|n| value(n))); self.frame = (self.frame + 1) % 720_720; } diff --git a/crates/horizon-client/src/gfx/wgsl_prefix.rs b/crates/horizon-client/src/gfx/wgsl_prefix.rs new file mode 100644 index 000000000..03d84a56e --- /dev/null +++ b/crates/horizon-client/src/gfx/wgsl_prefix.rs @@ -0,0 +1,124 @@ +//! Copies of the renderer's own WGSL that run beside a shader pack's code in one module (the +//! sky and material stages of Horizon's packs, docs/shaders.md): every name such a copy +//! declares takes a prefix starting with `horizon_`, which the pack check refuses in a pack, +//! so no pack name can meet one of Horizon's. + +/// terrain.wgsl, which the copies are taken from. +pub const TERRAIN: &str = include_str!("../terrain.wgsl"); + +/// The text of `source` from the line starting with `start` up to (not including) the one +/// starting with `end`. +pub fn section<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + let from = source + .find(&format!("\n{start}")) + .expect("the source has the section") + + 1; + let to = from + + source[from..] + .find(&format!("\n{end}")) + .expect("the source ends the section") + + 1; + &source[from..to] +} + +/// The names `text` declares at its top level: functions, structs, constants, overrides and +/// module variables (bindings). +fn declared(text: &str) -> Vec<&str> { + let mut names = Vec::new(); + for line in text.lines() { + // Top level only: a function's own `var` and `let` stay as they are. + if line.starts_with(char::is_whitespace) { + continue; + } + let mut line = line; + while let Some(rest) = line.strip_prefix('@') { + // An attribute: `@vertex`, `@group(0)`, `@binding(1)`. + let end = rest.find(' ').unwrap_or(rest.len()); + line = rest[end..].trim_start(); + } + let rest = ["fn ", "struct ", "const ", "override ", "var "] + .iter() + .find_map(|k| line.strip_prefix(k)) + .or_else(|| { + // `var name`, `var name`. + let rest = line.strip_prefix("var<")?; + Some(rest[rest.find('>')? + 1..].trim_start()) + }); + if let Some(rest) = rest { + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + names.push(&rest[..end]); + } + } + names +} + +/// `text` with every name it declares at its top level, and every use of one, prefixed by +/// `prefix`. Comments are rewritten too, which is harmless. +pub fn prefixed(text: &str, prefix: &str) -> String { + let names = declared(text); + let mut out = String::with_capacity(text.len() * 2); + let mut word = String::new(); + let flush = |word: &mut String, out: &mut String| { + if names.contains(&word.as_str()) { + out.push_str(prefix); + } + out.push_str(word); + word.clear(); + }; + for c in text.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + word.push(c); + } else { + flush(&mut word, &mut out); + out.push(c); + } + } + flush(&mut word, &mut out); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefixes_top_level_names_and_their_uses_only() { + let text = "struct S { a: f32, }\n\ + @group(0) @binding(0) var g: S;\n\ + @group(1) @binding(2) var list: array;\n\ + override N: u32 = 1u;\n\ + const K: f32 = 2.0;\n\ + @fragment fn f(x: f32) -> @location(0) vec4 {\n \ + var a = g.a * K;\n \ + return vec4(a + f32(list[N]) + x);\n\ + }\n"; + let out = prefixed(text, "p_"); + assert_eq!( + out, + "struct p_S { a: f32, }\n\ + @group(0) @binding(0) var p_g: p_S;\n\ + @group(1) @binding(2) var p_list: array;\n\ + override p_N: u32 = 1u;\n\ + const p_K: f32 = 2.0;\n\ + @fragment fn p_f(x: f32) -> @location(0) vec4 {\n \ + var a = p_g.a * p_K;\n \ + return vec4(a + f32(p_list[p_N]) + x);\n\ + }\n" + ); + // Every top-level declaration of terrain.wgsl is found. + let names = declared(TERRAIN); + for name in [ + "Globals", + "globals", + "sprites_7", + "SPRITE_SPLIT", + "block_slots", + "fs_sky", + ] { + assert!(names.contains(&name), "{name}"); + } + assert!(!names.contains(&"out")); + } +} diff --git a/crates/horizon-client/src/graphics.rs b/crates/horizon-client/src/graphics.rs index b51a586ab..4f6f04bc0 100644 --- a/crates/horizon-client/src/graphics.rs +++ b/crates/horizon-client/src/graphics.rs @@ -255,6 +255,12 @@ pub struct Graphics { pub resolution_scale: u32, /// FXAA over the world. pub fxaa: bool, + /// HDR output (scRGB), where the display path offers it. + pub hdr: bool, + /// HDR paper white (where SDR white sits), in nits. + pub hdr_paper_white: u32, + /// HDR peak brightness the world's highlights reach, in nits. + pub hdr_peak: u32, /// Battery mode: at most 60 frames per second in a world and 30 in menus /// or behind other windows, and half the worker threads. Off by default; /// it changes when frames are drawn, never what they show or the ticks. @@ -286,6 +292,9 @@ impl Default for Graphics { overlay: Overlay::Off, resolution_scale: 100, fxaa: false, + hdr: false, + hdr_paper_white: 200, + hdr_peak: 1000, battery: false, api: GraphicsApi::Auto, shader_pack: String::new(), @@ -337,6 +346,9 @@ impl Graphics { "workers" => g.workers = number(g.workers).min(64), "resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200), "fxaa" => g.fxaa = flag(g.fxaa), + "hdr" => g.hdr = flag(g.hdr), + "hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480), + "hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000), "batteryMode" => g.battery = flag(g.battery), "graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(), "pickedShaderPack" => g.shader_pack = value.to_owned(), @@ -354,7 +366,7 @@ impl Graphics { } pub fn serialize(&self) -> String { format!( - "preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n", + "preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n", self.preset.name(), self.lod_distance, self.far_detail.name(), @@ -379,6 +391,9 @@ impl Graphics { }, self.resolution_scale, bool_text(self.fxaa), + bool_text(self.hdr), + self.hdr_paper_white, + self.hdr_peak, bool_text(self.battery), self.api.name(), self.shader_pack, @@ -444,7 +459,7 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) { let g = &mut s.horizon; g.preset = preset; g.cave_culling = true; - g.animated_textures = preset != Preset::Performance; + g.animated_textures = true; g.chunk_updates = 0; g.upload_budget_kib = 0; g.sky = true; @@ -476,6 +491,9 @@ mod tests { overlay: Overlay::Graph, resolution_scale: 75, fxaa: true, + hdr: true, + hdr_paper_white: 240, + hdr_peak: 1400, battery: true, api: GraphicsApi::Dx12, shader_pack: "Golden Hour".into(), diff --git a/crates/horizon-client/src/graphics_ui.rs b/crates/horizon-client/src/graphics_ui.rs index ba99f89a8..3c7a40e02 100644 --- a/crates/horizon-client/src/graphics_ui.rs +++ b/crates/horizon-client/src/graphics_ui.rs @@ -37,6 +37,9 @@ pub enum H { Overlay, ResolutionScale, Antialiasing, + Hdr, + HdrPaperWhite, + HdrPeak, BatteryMode, GraphicsApi, } @@ -221,6 +224,21 @@ pub fn rows(level: Level) -> Vec { Medium, "FXAA smooths jagged edges of the world; slightly softer image.", ), + h( + H::Hdr, + None, + "HDR output on an HDR display: lava, fire and sunlight shine brighter.", + ), + h( + H::HdrPaperWhite, + None, + "HDR: how bright white paper and menus look. Match your desktop.", + ), + h( + H::HdrPeak, + None, + "HDR: how bright the brightest highlights get. Your display's peak.", + ), h( H::PresentMode, None, @@ -324,6 +342,8 @@ impl H { } H::Workers => Some(g.workers as f32 / max_workers() as f32), H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0), + H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0), + H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0), _ => None, } } @@ -365,6 +385,9 @@ impl H { }, H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)), H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }), + H::Hdr => labelled("HDR", on_off(g.hdr)), + H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)), + H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)), H::BatteryMode => labelled("Battery Mode", on_off(g.battery)), H::GraphicsApi => labelled("Graphics API", g.api.label()), H::Overlay => labelled( @@ -387,6 +410,8 @@ impl H { } H::Workers => g.workers = (v * max_workers() as f32).round() as u32, H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32, + H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32, + H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32, _ => {} } } @@ -418,6 +443,7 @@ impl H { H::AnimatedTextures => g.animated_textures = !g.animated_textures, H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back), H::Antialiasing => g.fxaa = !g.fxaa, + H::Hdr => g.hdr = !g.hdr, H::BatteryMode => g.battery = !g.battery, H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back), H::Pacing => { @@ -453,6 +479,7 @@ impl Item { Item::Horizon(H::Weather) => g.weather, Item::Horizon(H::AnimatedTextures) => g.animated_textures, Item::Horizon(H::Antialiasing) => g.fxaa, + Item::Horizon(H::Hdr) => g.hdr, Item::Horizon(H::BatteryMode) => g.battery, Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing, Item::Vanilla(Opt::Vsync) => s.vsync, diff --git a/crates/horizon-client/src/hdr.wgsl b/crates/horizon-client/src/hdr.wgsl new file mode 100644 index 000000000..bf6c27cca --- /dev/null +++ b/crates/horizon-client/src/hdr.wgsl @@ -0,0 +1,69 @@ +// Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma +// space, decoded to linear light at the paper white and written to the scRGB +// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the +// world as it was before the hand and HUD are expanded towards the peak. +@group(0) @binding(0) var frame: texture_2d; +@group(0) @binding(1) var world: texture_2d; + +struct Params { + // Paper white in scRGB units (nits / 80). + paper: f32, + // Peak brightness relative to paper white; 1 expands nothing. + peak: f32, + // 1 when this frame drew a world. + world: f32, + // 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1). + debug: f32, +}; +@group(0) @binding(2) var params: Params; + +// Light sources are lifted evenly rather than by a highlight curve: vanilla's +// textures were drawn for SDR, so a curve on their brightest pixels blows out +// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's +// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the +// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a +// lantern's metal), and no channel goes past the peak. Brightness is the mean +// of luminance and the brightest channel, so saturated light counts as bright. +const LIFT_START: f32 = 0.18; +const LIFT_FULL: f32 = 0.42; +const STRENGTH: f32 = 0.7; + +// Whether `c` still shows the world pixel `w`: the same, or darkened evenly +// across its channels, as the vignette and other screen-wide tints darken it. +// The hand, HUD and text replace pixels instead, so they never pass. +fn shows_world(c: vec3, w: vec3) -> bool { + if all(c == w) { + return true; + } + let k = c / max(w, vec3(1.0 / 255.0)); + let mean = (k.r + k.g + k.b) / 3.0; + return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03)); +} + +@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let p = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + return vec4(p * 2.0 - 1.0, 0.0, 1.0); +} + +@fragment fn fs_hdr(@builtin(position) pos: vec4) -> @location(0) vec4 { + let at = vec2(pos.xy); + let c = textureLoad(frame, at, 0).rgb; + // Gamma 2.2, as SDR displays and the compositors' SDR paths decode it. + var light = pow(max(c, vec3(0.0)), vec3(2.2)); + let w = textureLoad(world, at, 0); + // The world's alpha is 0 on light-emitting blocks, the sun and the moon + // (terrain.wgsl, the celestial pipeline): only those shine. + if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) { + let y = 0.5 * (dot(light, vec3(0.2126, 0.7152, 0.0722)) + max(light.r, max(light.g, light.b))); + let lift = smoothstep(LIFT_START, LIFT_FULL, y); + if lift > 0.0 { + let top = max(light.r, max(light.g, light.b)); + let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4)); + light *= max(gain, 1.0); + if params.debug > 0.5 { + light = mix(light, vec3(gain, 0.0, gain), 0.6); + } + } + } + return vec4(light * params.paper, 1.0); +} diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index 25c719bfb..46821df27 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -111,6 +111,12 @@ pub enum HealthEvent { amount: f32, }, Phase(&'static str), + /// `Player.hurtArmor`: a hit that doesn't bypass armor wears the worn + /// pieces (`LivingEntity.getDamageAfterArmorAbsorb`); the game applies it. + ArmorHurt { + source: DamageTypeId, + amount: f32, + }, /// `ServerLevel.broadcastDamageEvent`: the client's /// `LivingEntity.handleDamageEvent` plays the source's hurt sound. DamageBroadcast { @@ -139,6 +145,7 @@ impl HealthEvent { format!("record-damage {:08x}", amount.to_bits()) } Self::Phase(name) => name.into(), + Self::ArmorHurt { amount, .. } => format!("hurt-armor {:08x}", amount.to_bits()), Self::DamageBroadcast { .. } => "broadcast-damage".into(), Self::Criterion { .. } => "hurt-criterion".into(), } @@ -295,6 +302,20 @@ impl PlayerMotion { source: DamageTypeId, amount: f32, blocking: Option, + ) -> Result { + self.hurt_caused_by(resources, rules, source, amount, blocking, false) + } + /// `hurt_blocking` from a source whose causing entity is a living + /// non-player (`living_cause`), which the `when_caused_by_living_non_player` + /// damage types scale with the difficulty. + pub fn hurt_caused_by( + &mut self, + resources: &HealthResources, + rules: HealthRules, + source: DamageTypeId, + amount: f32, + blocking: Option, + living_cause: bool, ) -> Result { if self.client_side { return Ok(BlockOutcome::default()); @@ -315,6 +336,7 @@ impl PlayerMotion { invulnerable, rules, source, + living_cause, events: &mut self.events, dead: &mut h.dead, sync_velocity: &mut h.sync_velocity, @@ -390,6 +412,8 @@ pub(crate) struct Environment<'a> { pub(crate) invulnerable: bool, pub(crate) rules: HealthRules, pub(crate) source: DamageSource<'a, ()>, + /// The causing entity is a living non-player (a mob, or its projectile's owner). + pub(crate) living_cause: bool, pub(crate) events: &'a mut Vec, pub(crate) dead: &'a mut bool, pub(crate) sync_velocity: &'a mut bool, @@ -517,9 +541,14 @@ impl ServerDamageEnvironment for Environment<'_> { fn bypasses(&mut self, kind: Bypass) -> io::Result { Ok(self.source.bypasses(kind)) } - fn hurt_armor(&mut self, _: &mut Attributes, _: f32) -> io::Result<()> { + fn hurt_armor(&mut self, _: &mut Attributes, damage: f32) -> io::Result<()> { + // The worn armor lives in the game, which wears it from this event. + self.event(HealthEvent::ArmorHurt { + source: self.source.type_id(), + amount: damage, + }); Ok(()) - } // empty equipment + } fn has_resistance(&mut self) -> io::Result { Ok(self.status.resistance.is_some()) } @@ -773,8 +802,13 @@ impl PlayerHurtWorld for Environment<'_> { Ok(()) } fn scales_with_difficulty(&mut self, _: &mut State) -> io::Result { - Ok(self.source.definition().scaling == Scaling::Always) - } // no causing entity + // DamageSource.scalesWithDifficulty. + Ok(match self.source.definition().scaling { + Scaling::Never => false, + Scaling::Always => true, + Scaling::LivingNonPlayer => self.living_cause, + }) + } fn difficulty(&mut self, _: &mut State) -> io::Result { Ok(self.rules.difficulty) } diff --git a/crates/horizon-client/src/hud_overlays.rs b/crates/horizon-client/src/hud_overlays.rs index ecc23a5ee..ebd860e08 100644 --- a/crates/horizon-client/src/hud_overlays.rs +++ b/crates/horizon-client/src/hud_overlays.rs @@ -1111,8 +1111,9 @@ impl SubtitleOverlay { s.purge_old_instances(3000.0 * frame.multiplier, now); } if s.audible_from(position) && !s.played.is_empty() { - width = width.max(width_of(&Text::translate(&s.key, &[]))); - audible.push(i); + let text_width = width_of(&Text::translate(&s.key, &[])); + width = width.max(text_width); + audible.push((i, text_width)); } } if audible.is_empty() { @@ -1121,7 +1122,7 @@ impl SubtitleOverlay { let w = |s: &str| font.width(s) as i32; width += w("<") + w(" ") + w(">") + w(" "); let mut row = 0; - for i in audible { + for (i, text_width) in audible { let s = &self.subtitles[i]; let text_value = Text::translate(&s.key, &[]); let Some(closest) = s.best_candidate(position) else { @@ -1145,7 +1146,6 @@ impl SubtitleOverlay { let half_width = width / 2; let height = 9; let half_height = height / 2; - let text_width = width_of(&text_value); let brightness = s.brightness(closest, frame.multiplier, now); out.push(GuiDraw::Push(vec![ PoseOp::Translate( diff --git a/crates/horizon-client/src/hunger.rs b/crates/horizon-client/src/hunger.rs index 15eac144f..34f414d50 100644 --- a/crates/horizon-client/src/hunger.rs +++ b/crates/horizon-client/src/hunger.rs @@ -151,6 +151,7 @@ impl FoodWorld for TickWorld<'_> { .damage .source::<()>(id, None, None, None) .expect("validated starvation source"), + living_cause: false, events: self.events, dead: self.dead, sync_velocity: self.sync_velocity, diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-client/src/item_data.rs index c07e2f352..ac624e85b 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-client/src/item_data.rs @@ -204,6 +204,14 @@ impl ItemStack { pub fn custom_name(&self) -> Option<&str> { self.data().and_then(|d| d.custom_name.as_deref()) } + /// `minecraft:item_name` in the current language (`getItemName`'s + /// component). It stays in the patch as loaded, so a translatable name + /// saves and reaches clients unchanged; the ominous banner's is modelled + /// by `ominous_banner` instead. + pub fn item_name(&self) -> Option { + let name = self.data()?.other("minecraft:item_name")?; + Some(crate::entity_nbt::name_text(name).plain()) + } pub fn potion(&self) -> Option<&str> { self.data().and_then(|d| d.potion.as_deref()) } diff --git a/crates/horizon-client/src/item_render.rs b/crates/horizon-client/src/item_render.rs index c2f5c8400..4f981b874 100644 --- a/crates/horizon-client/src/item_render.rs +++ b/crates/horizon-client/src/item_render.rs @@ -363,6 +363,9 @@ pub struct ItemRenderer { sprites: FileSprites, baked: Mutex>>, extents: Mutex>>>, + /// GUI items already drawn: the HUD vertices of a static item at a GUI position + /// (`gui::draw`), which are the same every frame it stays there. + pub(crate) gui_drawn: Mutex>>, /// Entity model layers of the special renderers. parts: HashMap, /// `colormap/grass.png` as ARGB. @@ -452,6 +455,7 @@ impl ItemRenderer { }, baked: Mutex::default(), extents: Mutex::default(), + gui_drawn: Mutex::default(), parts, grass, }) diff --git a/crates/horizon-client/src/item_render/gui.rs b/crates/horizon-client/src/item_render/gui.rs index e3d7cc876..ff25fff1d 100644 --- a/crates/horizon-client/src/item_render/gui.rs +++ b/crates/horizon-client/src/item_render/gui.rs @@ -8,7 +8,7 @@ use super::{Foil, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost}; use crate::hud::{HudBatch, Rect}; use glam::{EulerRot, Mat4, Vec2, Vec3}; -use std::{collections::HashMap, f32::consts::PI}; +use std::{collections::HashMap, f32::consts::PI, sync::Arc}; mod depth; @@ -148,6 +148,95 @@ pub fn draw( if state.is_empty() { return; } + // A static item drawn where it was drawn before gives the same vertices: + // take them instead of submitting, sorting and clipping its quads again. + let key = DrawnKey::of(state, renderer, sprites, x, y); + if let Some(key) = &key + && let Some(drawn) = renderer + .gui_drawn + .lock() + .ok() + .and_then(|d| d.get(key).cloned()) + { + batch.vertices.extend_from_slice(&drawn); + return; + } + let start = batch.vertices.len(); + draw_now(state, renderer, sprites, host, batch, x, y, time); + if let Some(key) = key + && let Ok(mut drawn) = renderer.gui_drawn.lock() + { + // GUI positions and items are few; a long scroll through every tab stays bounded. + if drawn.len() >= DRAWN_LIMIT { + drawn.clear(); + } + drawn.insert(key, batch.vertices[start..].into()); + } +} + +/// Entries of `ItemRenderer::gui_drawn` before it starts over. +const DRAWN_LIMIT: usize = 8192; + +/// What a GUI item's vertices depend on: its layers' baked models (kept for the +/// renderer's life, so their addresses name them), tints and transforms, the +/// atlas, and the exact position. None for items whose drawing changes with time +/// or the host: a glint, or a special model renderer. +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) struct DrawnKey { + renderer: usize, + sprites: (usize, usize), + at: [u32; 2], + oversized: bool, + layers: Vec<(usize, Vec, [u32; 16], bool)>, +} +impl DrawnKey { + fn of( + state: &ItemRenderState, + renderer: &ItemRenderer, + sprites: &HashMap, + x: f32, + y: f32, + ) -> Option { + if state.animated { + return None; + } + let layers = state + .layers + .iter() + .map(|layer| match &layer.geometry { + super::LayerGeometry::Quads(baked) if layer.foil == super::FoilType::None => { + Some(( + Arc::as_ptr(baked) as usize, + layer.tints.clone(), + layer.matrix.to_cols_array().map(f32::to_bits), + layer.uses_block_light, + )) + } + _ => None, + }) + .collect::>>()?; + Some(Self { + renderer: std::ptr::from_ref(renderer) as usize, + sprites: (std::ptr::from_ref(sprites) as usize, sprites.len()), + at: [x.to_bits(), y.to_bits()], + oversized: state.oversized_in_gui, + layers, + }) + } +} + +/// [`draw`] without the cache. +#[allow(clippy::too_many_arguments)] +fn draw_now( + state: &ItemRenderState, + renderer: &ItemRenderer, + sprites: &HashMap, + host: &dyn SpecialHost, + batch: &mut HudBatch, + x: f32, + y: f32, + time: f32, +) { let pose = Mat4::from_translation(Vec3::new(x + 8.0, y + 8.0, 0.0)) * Mat4::from_scale(Vec3::new(16.0, -16.0, 16.0)); let mut quads: Vec = Vec::new(); diff --git a/crates/horizon-client/src/item_sync.rs b/crates/horizon-client/src/item_sync.rs index 930304ec4..fc35e15e7 100644 --- a/crates/horizon-client/src/item_sync.rs +++ b/crates/horizon-client/src/item_sync.rs @@ -238,6 +238,125 @@ mod tests { use super::*; use horizon_protocol::registry::Ids; + #[test] + fn merchant_water_cost_survives_the_offer_packet() { + use horizon_protocol::{ + buf::{Codec, Reader, Writer}, + packets::play::{NetOffer, Offers}, + }; + let mut components = vec![String::new(); component::POTION_CONTENTS as usize + 1]; + components[component::POTION_CONTENTS as usize] = "minecraft:potion_contents".into(); + let builtin = Builtin { + registries: [ + ( + "minecraft:item".into(), + Ids::from_names(vec!["minecraft:potion".into(), "minecraft:emerald".into()]), + ), + ( + "minecraft:potion".into(), + Ids::from_names(vec!["minecraft:water".into()]), + ), + ( + "minecraft:data_component_type".into(), + Ids::from_names(components), + ), + ] + .into(), + }; + let dynamic = Dynamic::default(); + let water = ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"); + let wire = crate::lan::guest::stack_to_network( + |name| builtin.get("minecraft:item").unwrap().id(name).unwrap(), + |registry, name| builtin.get(registry)?.id(name), + &Some(water.clone()), + ) + .unwrap(); + let offers = vec![NetOffer { + cost_a: wire.into(), + result: Stack::new(1, 1), + cost_b: Some(Stack::new(1, 2).into()), + out_of_stock: false, + uses: 0, + max_uses: 2, + xp: 0, + special_price: 0, + price_multiplier: 0.05, + demand: 0, + }]; + let mut w = Writer::new(); + Offers::write(&offers, &mut w); + let mut r = Reader::new(&w.data); + let decoded = Offers::read(&mut r).unwrap(); + assert_eq!(r.remaining(), 0); + let cost = crate::merchant::ItemCost { + stack: from_network(&decoded[0].cost_a.clone().into(), &builtin, &dynamic), + }; + assert_eq!(cost.stack, water); + assert_eq!(cost.stack.potion(), Some("minecraft:water")); + let authoritative = crate::merchant::ItemCost { + stack: water.clone(), + }; + for payment in [ + water, + ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"), + ItemStack::new("minecraft:potion", 1), + ] { + assert_eq!(cost.test(&payment), authoritative.test(&payment)); + } + assert!(cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"))); + assert!( + !cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness")) + ); + let emerald = crate::merchant::ItemCost { + stack: from_network( + &decoded[0].cost_b.clone().unwrap().into(), + &builtin, + &dynamic, + ), + }; + assert!(emerald.test(&ItemStack::new("minecraft:emerald", 2))); + } + + /// A bundle's contents cross the wire, so a server's client can draw + /// its tooltip (BundleContents' stream codec: a list of templates). + #[test] + fn a_bundle_keeps_its_contents_across_the_wire() { + let mut components = vec![String::new(); 60]; + components[component::BUNDLE_CONTENTS as usize] = "minecraft:bundle_contents".into(); + let items = ["minecraft:air", "minecraft:bundle", "minecraft:stone"]; + let builtin = Builtin { + registries: [ + ( + "minecraft:item".to_owned(), + Ids::from_names(items.iter().map(|s| s.to_string()).collect()), + ), + ( + "minecraft:data_component_type".to_owned(), + Ids::from_names(components), + ), + ] + .into(), + }; + let dynamic = Dynamic { + registries: Default::default(), + }; + let mut bundle = ItemStack::new("minecraft:bundle", 1); + bundle.data_mut().bundle_contents = vec![ItemStack::new("minecraft:stone", 32)]; + let wire = crate::lan::guest::stack_to_network( + |name| items.iter().position(|i| *i == name).unwrap_or(0) as i32, + |_, _| None, + &Some(bundle), + ) + .unwrap(); + let back = from_network(&wire, &builtin, &dynamic); + let contents = &back.data().expect("components").bundle_contents; + assert_eq!(contents.len(), 1); + assert_eq!( + (&*contents[0].item, contents[0].count), + ("minecraft:stone", 32) + ); + } + /// What a creative client sends (`stack_to_network`) comes back whole on /// the server: damage, both enchantment lists, repair cost, name, trim. #[test] @@ -246,6 +365,7 @@ mod tests { for (id, name) in [ (component::DAMAGE, "minecraft:damage"), (component::CUSTOM_NAME, "minecraft:custom_name"), + (component::ITEM_NAME, "minecraft:item_name"), (component::ENCHANTMENTS, "minecraft:enchantments"), (component::POTION_CONTENTS, "minecraft:potion_contents"), (component::REPAIR_COST, "minecraft:repair_cost"), @@ -305,6 +425,10 @@ mod tests { .push((Arc::from("minecraft:sharpness"), 2)); d.repair_cost = 9; d.custom_name = Some("Keeper".to_owned()); + // A translatable item name reaches the client as the same component. + let item_name = Tag::compound([("translate", Tag::String("filled_map.monument".into()))]); + d.other + .push(("minecraft:item_name".into(), item_name.clone())); d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry"))); let id = |registry: &str, name: &str| { builtin @@ -334,6 +458,7 @@ mod tests { ); assert_eq!(d.repair_cost, 9); assert_eq!(d.custom_name.as_deref(), Some("Keeper")); + assert_eq!(d.other("minecraft:item_name"), Some(&item_name)); assert_eq!( d.trim, Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry"))) diff --git a/crates/horizon-client/src/item_tooltip.rs b/crates/horizon-client/src/item_tooltip.rs index 6e51de391..88f01439e 100644 --- a/crates/horizon-client/src/item_tooltip.rs +++ b/crates/horizon-client/src/item_tooltip.rs @@ -56,10 +56,9 @@ impl<'a> TooltipDisplay<'a> { else { return true; }; - let component = horizon_world::nbt::JavaString::from(component); - !hidden - .iter() - .any(|tag| matches!(tag, Tag::String(id) if id == &component)) + !hidden.iter().any(|tag| { + matches!(tag, Tag::String(id) if id.0.iter().copied().eq(component.encode_utf16())) + }) } } @@ -189,6 +188,9 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option) if let Some(name) = stack.custom_name() { return name.to_owned(); } + if let Some(name) = stack.item_name() { + return name; + } if stack.data().is_some_and(|d| d.ominous_banner) { return translate("block.minecraft.ominous_banner") .unwrap_or_else(|| "Ominous Banner".to_owned()); @@ -612,6 +614,14 @@ mod tests { assert_eq!(rarity(&banner, "common"), "uncommon"); banner.data_mut().custom_name = Some("Captain's Standard".into()); assert_eq!(name_line(&banner, &translate), "Captain's Standard"); + let mut custom_item = ItemStack::new("minecraft:diamond_sword", 1); + custom_item.data_mut().other.push(( + "minecraft:item_name".into(), + Tag::String("Excalibur".into()), + )); + assert_eq!(name_line(&custom_item, &translate), "Excalibur"); + custom_item.data_mut().custom_name = Some("Custom Blade".into()); + assert_eq!(name_line(&custom_item, &translate), "Custom Blade"); banner.enchant("minecraft:unbreaking", 1); assert_eq!(rarity(&banner, "common"), "rare"); let plain = ItemStack::new("minecraft:white_banner", 1); diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index 8ac450bcb..905843d23 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -71,6 +71,8 @@ pub struct ItemInfo { /// The `tool` component's `damage_per_block`, when the item has the /// component (a mace or trident has one without rules). pub tool_damage_per_block: Option, + /// `tool.can_destroy_blocks_in_creative` (defaults to true). + pub can_destroy_blocks_in_creative: bool, pub food: Option<(u32, f32)>, pub can_always_eat: bool, /// `consumable`: its timing, sound, particles and `on_consume_effects`. @@ -111,6 +113,9 @@ pub struct ItemInfo { pub swappable: bool, /// `equippable.dispensable` (dispensers may put it on an entity). pub equip_dispensable: bool, + /// `equippable.damage_on_hurt`: the item wears when its wearer is hurt + /// (false for the elytra). + pub equip_damage_on_hurt: bool, /// `equippable.allowed_entities` resolved to entity type ids; None allows all. pub equip_allowed: Option>, pub equip_sound: Option, @@ -120,6 +125,8 @@ pub struct ItemInfo { pub crafting_remainder: Option, /// `dye`: the dye colour name (dyes, and anything else with the component). pub dye: Option, + /// `cushion/color`: the DyeColor name of a cushion item. + pub cushion_color: Option, /// `block_transformer`: the registry entry an axe, hoe or shovel uses on blocks. pub block_transformer: Option, /// Has a default `bundle_contents` (the bundles, `BundleItem`). @@ -251,6 +258,10 @@ pub struct Items { loot: HashMap, /// The `jukebox_song` registry. pub songs: HashMap, + /// The enchantment registry: loot functions enchant the items they roll + /// (`EnchantWithLevelsFunction`, `EnchantRandomlyFunction`), and the + /// station menus share this copy. + pub enchantments: Arc, } /// A `JukeboxSong`. #[derive(Clone, Debug)] @@ -392,6 +403,36 @@ impl Items { self.get(&stack.item).is_some_and(|i| i.fire_resistant) } + /// `ItemStack.canBeHurtBy` for the cactus damage type: a `damage_resistant` + /// component naming `minecraft:cactus`, or a damage type tag that holds it. + /// (`#minecraft:is_fire`, as on netherite gear, does not.) + pub fn resists_cactus(&self, stack: &ItemStack) -> bool { + const CACTUS_TAGS: [&str; 5] = [ + "#minecraft:bypasses_shield", + "#minecraft:no_knockback", + "#minecraft:panic_environmental_causes", + "#minecraft:panic_causes", + "#minecraft:sulfur_cube_with_block_immune_to", + ]; + let Some((_, v)) = stack.data().and_then(|data| { + data.other + .iter() + .find(|(key, _)| key == "minecraft:damage_resistant") + }) else { + return false; + }; + match v.get("types") { + Some(horizon_world::nbt::Tag::String(id)) => id + .to_string() + .is_ok_and(|id| id == "minecraft:cactus" || CACTUS_TAGS.contains(&id.as_str())), + Some(horizon_world::nbt::Tag::List(ids)) => ids.iter().any(|t| { + matches!(t, horizon_world::nbt::Tag::String(id) + if id.to_string().is_ok_and(|id| id == "minecraft:cactus")) + }), + _ => false, + } + } + pub fn load(root: &Path, registry: &BlockRegistry, facts: &BlockFacts) -> Result { let mut block_for_item: HashMap<&str, BlockStateId> = HashMap::new(); for (i, s) in facts.states.iter().enumerate() { @@ -552,6 +593,10 @@ impl Items { tool_damage_per_block: c .get("minecraft:tool") .map(|t| t["damage_per_block"].as_u64().unwrap_or(1) as u32), + can_destroy_blocks_in_creative: + c["minecraft:tool"]["can_destroy_blocks_in_creative"] + .as_bool() + .unwrap_or(true), food, can_always_eat, // Consumable.CODEC's defaults. @@ -584,6 +629,9 @@ impl Items { equip_dispensable: c["minecraft:equippable"]["dispensable"] .as_bool() .unwrap_or(true), + equip_damage_on_hurt: c["minecraft:equippable"]["damage_on_hurt"] + .as_bool() + .unwrap_or(true), equip_allowed: match &c["minecraft:equippable"]["allowed_entities"] { Value::String(spec) => Some(entity_spec(&entity_tags, spec)), Value::Array(list) => Some( @@ -599,6 +647,7 @@ impl Items { .map(str::to_owned), jukebox_song: c["minecraft:jukebox_playable"].as_str().map(str::to_owned), dye: c["minecraft:dye"].as_str().map(str::to_owned), + cushion_color: c["minecraft:cushion/color"].as_str().map(str::to_owned), block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned), bundle: c.get("minecraft:bundle_contents").is_some(), crafting_remainder: CRAFT_REMAINDERS @@ -701,12 +750,15 @@ impl Items { item_tag_order: load_tag_lists(root, "item")?, loot, songs: load_songs(root), + enchantments: Arc::new(crate::enchant::Enchantments::load(root)), }) } pub fn get(&self, id: &str) -> Option<&ItemInfo> { self.items.get(id) } - /// Only the block and item tags, for tools that need tag membership. + /// Only the block and item tags (and the enchantment registry, which the + /// station rules and loot functions are built from), for tools that need + /// tag membership. pub fn tags_only(root: &Path) -> Result { Ok(Self { items: HashMap::new(), @@ -717,6 +769,7 @@ impl Items { item_tag_order: load_tag_lists(root, "item")?, loot: HashMap::new(), songs: HashMap::new(), + enchantments: Arc::new(crate::enchant::Enchantments::load(root)), }) } /// An item table with only tags, for unit tests of item rules. @@ -734,6 +787,7 @@ impl Items { item_tags, loot: HashMap::new(), songs: HashMap::new(), + enchantments: Arc::new(crate::enchant::Enchantments::default()), } } /// Whether `block` is in the block tag `tag` (`minecraft:fire`, no `#`). @@ -746,6 +800,26 @@ impl Items { None => spec == block, } } + /// `Item.canDestroyBlock`: a tool may forbid block destruction with instabuild. + pub fn can_destroy_block(&self, held: Option<&ItemStack>, creative: bool) -> bool { + if !creative { + return true; + } + let Some(held) = held.filter(|s| s.count > 0 && &*s.item != "minecraft:air") else { + return true; + }; + if let Some(tool) = held.data().and_then(|d| d.other("minecraft:tool")) { + return tool + .get("can_destroy_blocks_in_creative") + .and_then(|v| match v { + horizon_world::nbt::Tag::Byte(v) => Some(*v), + _ => None, + }) + .is_none_or(|v| v != 0); + } + self.get(&held.item) + .is_none_or(|info| info.can_destroy_blocks_in_creative) + } /// `Tool.getMiningSpeed` for the held item (1.0 without a tool component). pub fn mining_speed(&self, held: Option<&ItemStack>, block: &str) -> f32 { let Some(info) = held.and_then(|s| self.get(&s.item)) else { @@ -796,6 +870,7 @@ impl Items { held: None, killed_by_player: false, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -860,10 +935,26 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, ) -> Vec { - self.block_drops_exploded(registry, state, held, dynamic, random, sequences, None) + self.block_drops_exploded( + registry, state, held, dynamic, random, sequences, None, false, + ) + } + /// `block_drops_with` for a block a player breaks (`Block.playerDestroy`): + /// the player is the loot context's `this` entity, so snow and chorus + /// flower tables see an entity. + pub fn block_drops_by_player( + &self, + registry: &BlockRegistry, + state: BlockStateId, + held: Option<&ItemStack>, + dynamic: &[ItemStack], + random: &mut impl RandomSource, + ) -> Vec { + self.block_drops_exploded(registry, state, held, dynamic, random, None, None, true) } /// `block_drops_full` for a block an explosion destroys: `explosion` - /// is its radius when the explosion decays drops (`DESTROY_WITH_DECAY`). + /// is its radius when the explosion decays drops (`DESTROY_WITH_DECAY`); + /// `breaker` is whether an entity (the player) broke the block. #[allow(clippy::too_many_arguments)] pub fn block_drops_exploded( &self, @@ -874,8 +965,10 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, explosion: Option, + breaker: bool, ) -> Vec { - let drops = self.block_drops_raw(registry, state, held, random, sequences, explosion); + let drops = + self.block_drops_raw(registry, state, held, random, sequences, explosion, breaker); if !drops.iter().any(|d| d.item.starts_with(DYNAMIC)) { return drops; } @@ -890,6 +983,7 @@ impl Items { }) .collect() } + #[allow(clippy::too_many_arguments)] fn block_drops_raw( &self, registry: &BlockRegistry, @@ -898,6 +992,7 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, explosion_radius: Option, + breaker: bool, ) -> Vec { let Some(block) = registry.state(state) else { return vec![]; @@ -931,6 +1026,7 @@ impl Items { held, killed_by_player: false, explosion_radius, + this_entity: breaker, }; return ctx.evaluate(table, random); } @@ -941,6 +1037,7 @@ impl Items { held, killed_by_player: false, explosion_radius, + this_entity: breaker, }; ctx.evaluate(table, random) } @@ -964,6 +1061,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -996,6 +1094,7 @@ impl Items { held: None, killed_by_player: false, explosion_radius: None, + this_entity: false, }; match table.get("random_sequence").and_then(Value::as_str) { Some(sequence) => ctx.evaluate(table, sequences.get(sequence, seed)), @@ -1021,6 +1120,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -1043,6 +1143,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -1084,6 +1185,8 @@ struct LootContext<'a> { killed_by_player: bool, /// `LootContextParams.EXPLOSION_RADIUS`: an explosion that decays drops. explosion_radius: Option, + /// `LootContextParams.THIS_ENTITY` is set (a player broke the block). + this_entity: bool, } impl LootContext<'_> { /// Level of an enchantment on the tool / killer's weapon. @@ -1244,6 +1347,15 @@ impl LootContext<'_> { }; random.next_float() < chance } + // `this` with no checks (snow, chorus flower) holds when an entity + // broke the block (LootItemEntityPropertyCondition needs the entity). + "minecraft:entity_properties" + if self.this_entity + && c["entity"].as_str() == Some("this") + && c["predicate"].as_object().is_none_or(|p| p.is_empty()) => + { + true + } // Entity flags, equipment and damage-source predicates: none hold // for an unenchanted player kill without fire or lightning. "minecraft:entity_properties" | "minecraft:damage_source_properties" => false, @@ -1371,6 +1483,16 @@ impl LootContext<'_> { self.modifiers(&modifiers, &mut stack, random); produced.push(stack); } + /// An enchantment function's `options` (`HolderSet`): a tag, a list or + /// one id; every enchantment when absent (`registryAccess().listElements`). + fn enchant_pool(e: &crate::enchant::Enchantments, options: &Value) -> Vec> { + match options { + Value::String(s) if s.starts_with('#') => e.tag(s).to_vec(), + Value::String(s) => vec![Arc::from(s.as_str())], + Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(Arc::from)).collect(), + _ => e.list.iter().map(|x| Arc::clone(&x.id)).collect(), + } + } fn modifiers(&self, m: &Value, stack: &mut ItemStack, random: &mut R) { let list: Vec<&Value> = match m { Value::Array(a) => a.iter().collect(), @@ -1449,6 +1571,54 @@ impl LootContext<'_> { .with_potion(id); } } + // EnchantWithLevelsFunction: EnchantmentHelper.enchantItem on + // the context's random (the End cities' gear). + "minecraft:enchant_with_levels" => { + let cost = Self::int_number(&f["levels"], random); + let e = &self.items.enchantments; + let pool = Self::enchant_pool(e, &f["options"]); + let chosen = e.select(self.items, random, &stack.item, cost, &pool); + // EnchantmentHelper.enchantItem: a book becomes one + // enchanted book (the fresh stack's count). + if &*stack.item == "minecraft:book" { + *stack = ItemStack::new("minecraft:enchanted_book", 1); + } + for (id, level) in chosen { + stack.enchant(&id, level); + } + } + // EnchantRandomlyFunction: one enchantment the item accepts, + // at a level between its min and max. + "minecraft:enchant_randomly" => { + let e = &self.items.enchantments; + let book = &*stack.item == "minecraft:book"; + let only_compatible = f["only_compatible"].as_bool().unwrap_or(true); + let options: Vec> = Self::enchant_pool(e, &f["options"]) + .into_iter() + .filter(|id| { + book || !only_compatible || e.can_enchant(self.items, id, &stack.item) + }) + .collect(); + if options.is_empty() { + continue; + } + let id = options + [random.next_int_bound(options.len() as i32).unwrap_or(0) as usize] + .clone(); + let max = e.get(&id).map_or(1, |d| d.max_level).max(1); + // `Mth.nextInt(random, 1, max)`: a single-level + // enchantment (Mending) returns 1 without drawing, so the + // rolls after this one keep vanilla's sequence. + let level = if max <= 1 { + 1 + } else { + 1 + random.next_int_bound(max as i32).unwrap_or(0) as u32 + }; + if book { + *stack = ItemStack::new("minecraft:enchanted_book", 1); + } + stack.enchant(&id, level); + } "minecraft:limit_count" => { let limit = &f["limit"]; if let Some(min) = limit["min"].as_i64() { @@ -1571,6 +1741,7 @@ mod tests { held, killed_by_player: block.is_empty(), explosion_radius: None, + this_entity: false, } } @@ -1658,6 +1829,102 @@ mod tests { assert_eq!(drops[0].potion(), Some("minecraft:fire_resistance")); } + /// `/loot give` and every other table this dispatcher rolls: the two + /// enchantment functions (`EnchantWithLevelsFunction`, + /// `EnchantRandomlyFunction`) put their enchantments on the stack. + #[test] + fn loot_enchant_functions_enchant_the_rolled_item() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("items.json").exists() { + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap(); + let items = Items::load(&root, assets.registry, &facts).unwrap(); + let props = BTreeMap::new(); + let mut random = LegacyRandom::new(11); + // A sword at levels 20..39 comes out enchanted, as in an End city. + let levels = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item", + "name":"minecraft:diamond_sword","modifier":[ + {"type":"minecraft:enchant_with_levels","levels":{"type":"minecraft:uniform","min":20.0,"max":39.0}}]}]}]}); + let mut enchanted = 0; + for _ in 0..40 { + let drops = ctx(&items, None, &props, "").evaluate(&levels, &mut random); + if drops[0].enchantments().is_empty() { + continue; + } + enchanted += 1; + assert_eq!(&*drops[0].item, "minecraft:diamond_sword"); + // Levels stay within the enchantment's range. + for (id, level) in drops[0].enchantments() { + let max = items + .enchantments + .get(id) + .expect("a known enchantment") + .max_level; + assert!((1..=max).contains(level), "{id} at {level}"); + } + } + assert!( + enchanted > 30, + "only {enchanted} of 40 rolls were enchanted" + ); + // `enchant_randomly` on a book makes one enchanted book, its levels + // stored; the pool is the enchantment tag it names. + let book = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item", + "name":"minecraft:book","modifier":[ + {"type":"minecraft:enchant_randomly","options":"#minecraft:on_random_loot"}]}]}]}); + let drops = ctx(&items, None, &props, "").evaluate(&book, &mut random); + assert_eq!(&*drops[0].item, "minecraft:enchanted_book"); + assert_eq!(drops[0].count, 1); + assert!(drops[0].enchantments().is_empty()); + let stored = drops[0].stored_enchantments(); + assert_eq!(stored.len(), 1, "{stored:?}"); + assert!( + items + .enchantments + .in_tag("minecraft:on_random_loot", &stored[0].0) + ); + // An item no enchantment accepts keeps none of them. + let dirt = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item", + "name":"minecraft:dirt","modifier":[ + {"type":"minecraft:enchant_with_levels","levels":{"type":"minecraft:constant","value":30.0}}]}]}]}); + let drops = ctx(&items, None, &props, "").evaluate(&dirt, &mut random); + assert!(drops[0].enchantments().is_empty()); + } + + /// `Mth.nextInt(random, 1, max)` returns `1` without drawing when the + /// enchantment has one level (`EnchantRandomlyFunction.enchantItem`), so + /// the rolls after it must not shift. Vanilla with `LegacyRandom(11)`: a + /// Mending book, then `set_count` uniform 1..64, is 46. + #[test] + fn a_single_level_enchantment_draws_no_level() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("items.json").exists() { + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap(); + let items = Items::load(&root, assets.registry, &facts).unwrap(); + let props = BTreeMap::new(); + let table = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item", + "name":"minecraft:book","modifier":[ + {"type":"minecraft:enchant_randomly","options":"minecraft:mending"}, + {"type":"minecraft:set_count","count":{"type":"minecraft:uniform","min":1.0,"max":64.0}}]}]}]}); + let drops = ctx(&items, None, &props, "").evaluate(&table, &mut LegacyRandom::new(11)); + assert_eq!(drops.len(), 1); + assert_eq!(&*drops[0].item, "minecraft:enchanted_book"); + assert_eq!( + drops[0].stored_enchantments(), + [("minecraft:mending".to_owned().into(), 1)], + "the book keeps its single-level enchantment" + ); + // 46 is vanilla's count for this seed; an extra level draw gives 28. + assert_eq!(drops[0].count, 46); + } + #[test] fn always_edible_and_consume_effects_loaded() { let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/horizon-client/src/keybinds.rs b/crates/horizon-client/src/keybinds.rs index a59aa627c..7575fb953 100644 --- a/crates/horizon-client/src/keybinds.rs +++ b/crates/horizon-client/src/keybinds.rs @@ -275,6 +275,20 @@ impl Default for KeyBinds { } } } +/// Each default key once, in order: two mappings that share a default key +/// (Advancements and the debug profiler both on L) are one press of it, not +/// two (the second would close the screen the first opened). Vanilla clicks +/// both mappings, but the profiler one only acts with F3 held. +fn distinct(keys: Vec) -> Vec { + let mut out = Vec::with_capacity(keys.len()); + for k in keys { + if !out.contains(&k) { + out.push(k); + } + } + out +} + impl KeyBinds { pub fn get(&self, name: &str) -> &str { self.keys @@ -320,49 +334,80 @@ impl KeyBinds { } /// The mapping a physical input triggers (first in list order). pub fn mapping_for(&self, key: &str) -> Option<&'static str> { - MAPPINGS.iter().map(|m| m.0).find(|n| self.get(n) == key) + self.mappings_for(key).next() + } + /// Every mapping a physical input triggers, in list order: like + /// `KeyMapping.set`/`click`, one key drives all the mappings bound to it. + pub fn mappings_for<'a>(&'a self, key: &'a str) -> impl Iterator + 'a { + MAPPINGS + .iter() + .map(|m| m.0) + .filter(move |n| self.get(n) == key) } /// Translate a physical key into the default key of the mapping it is /// bound to, so game code written against vanilla defaults follows the /// player's bindings. `None` means the key now does nothing; mouse - /// mappings bound to keys come back as `Err(button)`. + /// mappings bound to keys come back as `Err(button)`. The first of + /// [`KeyBinds::translate_all`]. pub fn translate(&self, key: KeyCode) -> Option> { + self.translate_all(key).into_iter().next() + } + /// The default keys of every mapping `key` is bound to (empty when it + /// now does nothing), so a key bound to two mappings (forward and + /// sprint) drives both, as `KeyMapping.set` does. + pub fn translate_all(&self, key: KeyCode) -> Vec> { let Some(name) = key_name(key) else { - return Some(Ok(key)); + return vec![Ok(key)]; }; - if let Some(mapping) = self.mapping_for(name) { - let default = Self::default_of(mapping); - if let Some(code) = key_code(default) { - return Some(Ok(code)); - } - return mouse_button(default).map(Err); + let bound: Vec<_> = self + .mappings_for(name) + .filter_map(|mapping| { + let default = Self::default_of(mapping); + match key_code(default) { + Some(code) => Some(Ok(code)), + None => mouse_button(default).map(Err), + } + }) + .collect(); + if !bound.is_empty() { + return distinct(bound); } // An unbound key whose default meaning was moved elsewhere is inert. if MAPPINGS .iter() .any(|(n, d, _)| *d == name && !self.is_default(n)) { - return None; + return Vec::new(); } - Some(Ok(key)) + vec![Ok(key)] } /// Translate a mouse button the same way (keys bound to mouse buttons). pub fn translate_mouse(&self, button: MouseButton) -> Option> { + self.translate_mouse_all(button).into_iter().next() + } + /// [`KeyBinds::translate_all`] for a mouse button. + pub fn translate_mouse_all(&self, button: MouseButton) -> Vec> { let name = mouse_name(button); - if let Some(mapping) = self.mapping_for(&name) { - let default = Self::default_of(mapping); - if let Some(b) = mouse_button(default) { - return Some(Err(b)); - } - return key_code(default).map(Ok); + let bound: Vec<_> = self + .mappings_for(&name) + .filter_map(|mapping| { + let default = Self::default_of(mapping); + match mouse_button(default) { + Some(b) => Some(Err(b)), + None => key_code(default).map(Ok), + } + }) + .collect(); + if !bound.is_empty() { + return distinct(bound); } if MAPPINGS .iter() .any(|(n, d, _)| *d == name && !self.is_default(n)) { - return None; + return Vec::new(); } - Some(Err(button)) + vec![Err(button)] } /// `AbstractContainerScreen.checkHotbarMouseClicked`: off hand, then hotbar. pub fn container_mouse_key(&self, button: MouseButton, creative: bool) -> Option { diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index d7dcc6cd8..122d37bcd 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -952,6 +952,11 @@ pub fn entity_tag(entity: &BlockEntity) -> Option { Some(Tag::compound(entries)) } BlockEntity::Jukebox(_) | BlockEntity::Lectern(_) => None, + // CampfireBlockEntity.getUpdateTag: the items alone. + BlockEntity::Campfire(c) => Some(Tag::compound([( + "Items", + crate::save::encode_items(&c.items), + )])), _ => None, } } diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index fb91f3006..f2dcf701d 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -823,6 +823,17 @@ fn login_and_configure( _ => {} } }; + // A Horizon client hears that this server runs its mods' quiet commands + // (horizon_protocol::quiet); a vanilla one is told nothing. + if brand == "horizon" { + send( + c, + cfg::Clientbound::CustomPayload(cfg::CustomPayload { + channel: horizon_protocol::quiet::CHANNEL.into(), + data: Vec::new(), + }), + )?; + } for packet in sync.registry_packets(known_pack) { send(c, cfg::Clientbound::RegistryData(packet))?; } diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 7e150e7bd..002f95cbb 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -120,6 +120,21 @@ impl Hub { ); } + /// `ServerPlayer.crit`: send the victim's animation to the players tracking + /// the attacker and the attacker themselves (`sendToTrackingPlayersAndSelf`). + pub fn critical_hit(&self, attacker: i32, target: i32) { + let body = C::Animate(play::Animate { + id: target, + action: 1, + }) + .encode(); + for g in self.guests.iter().filter(|g| { + self.here(g) && (g.entity_id == attacker || g.tracker.is_tracking(attacker)) + }) { + g.sender.send(body.clone()); + } + } + /// `ServerLevel.broadcastDamageEvent`: the clients around show the hurt /// entity's flash and hear its hurt sound. pub fn damage_event( diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index aa1994257..7e43cf04e 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -237,6 +237,8 @@ pub struct Guest { pub drag: Option<(u8, Vec)>, /// The compound this player was loaded from, for fields Horizon does not model. pub saved: Option, + /// `ServerPlayer.seenCredits`: the end poem has been shown. + pub seen_credits: bool, /// `ServerPlayer.recipeBook`. pub recipe_book: crate::recipe_book::Settings, /// `PlayerAdvancements`: the criteria obtained, whether they came from @@ -358,6 +360,7 @@ impl Guest { next_window: 0, drag: None, saved: None, + seen_credits: false, recipe_book: Default::default(), advancements: Default::default(), advancements_loaded: false, @@ -601,6 +604,18 @@ fn network_stack( horizon_protocol::item::Component::Stacks(charged), )); } + // What a bundle holds, for its tooltip (ClientBundleTooltip). + if !d.bundle_contents.is_empty() { + let contents = d + .bundle_contents + .iter() + .filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone()))) + .collect(); + stack.added.push(( + horizon_protocol::item::component::BUNDLE_CONTENTS, + horizon_protocol::item::Component::Stacks(contents), + )); + } // The books a lectern shows and a guest reads. if !d.writable_pages.is_empty() { stack.added.push(( @@ -633,6 +648,13 @@ fn network_stack( horizon_protocol::item::Component::Text(horizon_protocol::text::literal(name)), )); } + // The component as loaded, so a translatable name stays one. + if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) { + stack.added.push(( + horizon_protocol::item::component::ITEM_NAME, + horizon_protocol::item::Component::Text(name.clone()), + )); + } Some(stack) } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 5fc7d9b23..05cb8d2ac 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -380,9 +380,21 @@ impl Hub { id } pub fn stack(&mut self, s: &Option) -> Option { - let id = s.as_ref().map(|s| self.item_id(&s.item))?; + let item = s.as_ref()?.item.clone(); + let id = self.item_id(&item); let sync = self.sync.clone(); - stack_to_network(|_| id, |registry, name| sync.id(registry, name), s) + // The stacks inside it (a bundle's contents, a crossbow's projectiles) + // have ids of their own. + let item_id = |name: &str| { + if name == &*item { + return id; + } + sync.builtin + .get("minecraft:item") + .and_then(|i| i.id(name)) + .unwrap_or(0) + }; + stack_to_network(item_id, |registry, name| sync.id(registry, name), s) } pub fn broadcast(&self, packet: &C) { @@ -2207,17 +2219,24 @@ impl Hub { if g.spectator() || g.sleeping { return (false, false); } - let height = if g.fall_flying { - 0.6 + // getBoundingBox: the box of the body's pose (swimming and crawling + // are 0.6 tall), so a swimmer under a ceiling isn't sent back. + let (half, height) = if g.fall_flying { + (0.3, 0.6) + } else if let Some(m) = &g.motion { + ( + f64::from(m.dimensions.width) / 2.0, + f64::from(m.dimensions.height), + ) } else if g.sneaking { - 1.5 + (0.3, 1.5) } else { - 1.8 + (0.3, 1.8) }; let at = |p: [f64; 3]| { Aabb::new( - Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3), - Vec3::new(p[0] + 0.3, p[1] + height, p[2] + 0.3), + Vec3::new(p[0] - half, p[1], p[2] - half), + Vec3::new(p[0] + half, p[1] + height, p[2] + half), ) }; let (old, new) = (at(g.position), at(to)); diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index 4185dadc4..859bafa23 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -37,6 +37,10 @@ pub struct Menu { pub sent_data: Vec, } +/// The y of the key a chest minecart's or chest boat's items are kept under +/// while a menu shows them. +pub const VEHICLE_KEY_Y: i32 = -100_000; + /// Where the slots of a container menu live. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Slots { @@ -72,10 +76,12 @@ impl Slots { Slots::Ender => at.into_iter().collect(), _ => self.halves(), }; + // A vehicle's container is kept under a key, not a block: the game + // checks its reach to the vehicle (`isChestVehicleStillValid`). self.valid(world) && blocks .iter() - .all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0)) + .all(|k| k.1 == VEHICLE_KEY_Y || guest.within_block_range([k.0, k.1, k.2], 4.0)) } /// Container slot `n`. pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option { diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 90fe0bcfe..f5eb935c9 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -85,6 +85,7 @@ pub fn apply(guest: &mut Guest, level: &Level) { .map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]); guest.respawn_elsewhere = level.respawn_elsewhere.clone(); guest.saved = level.player_extra.clone(); + guest.seen_credits = level.seen_credits; let extra = level.player_extra.as_ref(); let short = |key: &str, default: i64| { extra @@ -166,6 +167,7 @@ pub fn store(guest: &Guest, level: &mut Level) { level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]); level.respawn_elsewhere = guest.respawn_elsewhere.clone(); level.on_ground = guest.on_ground; + level.seen_credits = guest.seen_credits; level.player_extra = guest.saved.clone(); // The live body's fire, air and fall distance (Entity.saveWithoutId), // or what the file had while the player has no body yet. diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 6bf40d1f0..ebfbdea58 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -50,6 +50,7 @@ pub mod cooldowns; pub mod crafting; pub mod crash; pub mod creative; +pub mod cushion; pub mod data_cmd; pub mod datapack; pub mod debug_chart; diff --git a/crates/horizon-client/src/lod.rs b/crates/horizon-client/src/lod.rs index a429863a7..1ee61cdf4 100644 --- a/crates/horizon-client/src/lod.rs +++ b/crates/horizon-client/src/lod.rs @@ -752,6 +752,25 @@ impl Lod { saved_order: VecDeque::new(), } } + /// The solid ground at block column (x, z): its top face's height and + /// its block, from the finest tile sampled there. For aiming past the + /// resident chunks (`game.raycast`); water is passed, as the view's ray + /// passes it. + pub fn ground(&self, x: i32, z: i32) -> Option<(i32, BlockStateId)> { + (0..=MAX_LEVEL).find_map(|level| { + let size = 16 << level; + let key = TileKey { + x: x.div_euclid(size), + z: z.div_euclid(size), + level, + }; + let columns = self.ready.get(&key).filter(|c| c.len() == CELLS * CELLS)?; + let cell = size / CELLS as i32; + let (cx, cz) = ((x - key.x * size) / cell, (z - key.z * size) / cell); + let surface = columns[cz as usize * CELLS + cx as usize].surface?; + Some((surface.height, surface.state)) + }) + } pub fn stats(&self) -> (usize, usize) { (self.active.len(), self.ready.len()) } diff --git a/crates/horizon-client/src/main.rs b/crates/horizon-client/src/main.rs index 8004b2fa7..7144a1670 100644 --- a/crates/horizon-client/src/main.rs +++ b/crates/horizon-client/src/main.rs @@ -4,7 +4,13 @@ mod game; use horizon_client::{crash, logging}; +/// The mod API level this game serves, kept in the program for the launcher +/// to read without running it (`horizon_mods::api_level::MARK`). `main` +/// reads it, so the linker keeps it. +static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK; + fn main() { + std::hint::black_box(&MOD_API_LEVEL); horizon_client::telemetry::mark_start(); let args: Vec = std::env::args().skip(1).collect(); let game_args = args.iter().take_while(|a| *a != "--dedicated"); diff --git a/crates/horizon-client/src/mesher.rs b/crates/horizon-client/src/mesher.rs index fff3c6aa0..48d2f08bd 100644 --- a/crates/horizon-client/src/mesher.rs +++ b/crates/horizon-client/src/mesher.rs @@ -113,6 +113,14 @@ mod vertex_tests { assert!((a - b).abs() <= 0.5 / 32768.0, "{a} {b}"); } } + #[test] + fn emission_keeps_the_colour_and_the_free_bits() { + let rgb = super::pack_rgb([0x12, 0x34, 0x56]); + assert_eq!(super::emitting(rgb, 0), rgb); + assert_eq!(super::emitting(rgb, 9), 0xF656_3412); + assert_eq!(super::emitting(rgb, 15), 0xF056_3412); + assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb); + } } pub struct SectionMesh { pub solid: Vec, @@ -566,6 +574,14 @@ impl Padded { } /// Set on a terrain vertex's sprite layer when its texture alpha must be ignored. pub const OPAQUE_LAYER: u16 = 1 << 15; +/// The bits of `TerrainVertex::color`'s alpha byte that hold the light its face emits, for +/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none. +/// The byte's other four bits stay set. +pub const EMISSION_BITS: u32 = 0x0F00_0000; +/// `color` marked as emitting light `level` (0 to 15). +pub fn emitting(color: u32, level: u8) -> u32 { + color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24 +} const UNSET: u32 = u32::MAX; const NO_BIOME: u16 = u16::MAX; /// The `ColorResolver`s `ClientLevel` caches block tints for. @@ -880,7 +896,7 @@ impl Mesher<'_> { } else { face.layer as u16 }, - pack_rgb(rgb), + emitting(pack_rgb(rgb), state.emission.max(q.light_emission)), pack_coords(lighting::with_emission(coords[i], q.light_emission)), )); } diff --git a/crates/horizon-client/src/mesher/fluid.rs b/crates/horizon-client/src/mesher/fluid.rs index 6e42685ba..d30b914a4 100644 --- a/crates/horizon-client/src/mesher/fluid.rs +++ b/crates/horizon-client/src/mesher/fluid.rs @@ -227,8 +227,11 @@ pub(super) fn tesselate( } else { 0xFF_FFFF }; + // Lava emits light level 15. + let emission = if water { 0 } else { 15 }; let color = |by: f32| { - pack_rgb([16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32)) + let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32); + crate::mesher::emitting(pack_rgb(rgb), emission) }; let height = |p: [i32; 3]| -> f32 { let s = at(p); diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..f4086216d 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -25,6 +25,7 @@ pub mod creaking; pub mod creaking_heart; pub mod cube; pub mod data; +pub mod doors; pub mod drops; pub mod enderman; pub mod equine; @@ -68,6 +69,7 @@ pub mod tame; pub mod trader; pub mod transport_items; pub mod trial_spawner; +pub mod turtle; pub mod undead; pub mod vex; pub mod villager; @@ -665,6 +667,8 @@ pub struct Extra { pub chested: bool, /// The chest contents of a donkey or mule (15 slots). pub items: Vec>, + /// `Piglin.inventory` (`SimpleContainer(8)`): what it picked up and kept. + pub inventory: Vec, pub tame: bool, pub sitting: bool, /// The owner's UUID (tamed animals). @@ -709,6 +713,9 @@ pub struct Extra { /// A copper golem's `openedChestPos`. pub opened_chest: Option<[i32; 3]>, pub point: Option<[f64; 3]>, + /// `NEAREST_REPELLENT`: the nearest repelling block a hoglin or piglin + /// sensed, looked up every 20 ticks. + pub repellent: Option<[i32; 3]>, pub vector: [f64; 3], /// Active status effects: (effect id, ticks left, amplifier). pub effects: Vec<(&'static str, i32, i32)>, @@ -868,6 +875,11 @@ pub struct Mob { /// Events a mob tick asks the game to act on. #[derive(Clone, Debug)] pub enum MobEvent { + /// Squid.handleEntityEvent(19), resetting the remote tentacle cycle. + SquidCycle { + mob: u32, + at: [f64; 3], + }, /// Rabbit entity event 1, sent only when a ground jump actually happens. RabbitHop { mob: u32, @@ -965,6 +977,14 @@ pub enum MobEvent { owner: u32, }, Shoot([f64; 3], [f64; 3]), + /// The mob the `Explode` right after this comes from + /// (`ServerExplosion.source`), and whether it is a charged creeper: the + /// exploding creeper is already out of the mob list when the game applies + /// the blast, so its victims' loot cannot look it up. + ExplosionSource { + mob: u32, + charged: bool, + }, Explode([f64; 3], f32), /// Melee or ranged damage to another mob. AttackMob { @@ -1001,6 +1021,24 @@ pub enum MobEvent { pos: [i32; 3], open: bool, }, + /// `BreakDoorGoal.tick` at its end: the door is removed (`removeBlock`), + /// then level events 1021 and 2001 with the state that is left there. + BreakDoor { + pos: [i32; 3], + }, + /// `Level.destroyBlockProgress` for a mob's crack on a block (`-1` + /// clears it). + DestroyProgress { + mob: u32, + pos: [i32; 3], + progress: i32, + }, + /// `Level.levelEvent`: the game plays and sends the event. + LevelEvent { + id: i32, + pos: [i32; 3], + data: i32, + }, /// Destroy a block, dropping nothing (sheep eating grass becomes dirt via SetBlock). BreakBlock { pos: [i32; 3], @@ -1098,6 +1136,13 @@ pub enum MobEvent { partner: u32, child: Option<([i32; 3], Option)>, }, + /// `WorkAtComposter.useWorkstation`: a farmer at its composter makes + /// bread and composts seeds (the game applies it to the villager's + /// inventory and the block). + WorkAtComposter { + mob: u32, + pos: [i32; 3], + }, /// A villager asks whether enough neighbours want an iron golem. GolemCheck { mob: u32, @@ -1126,8 +1171,10 @@ pub enum MobEvent { target: Who, aggressive: bool, }, - /// Drop the loot of a gift table (chicken eggs, armadillo scutes). + /// Drop the loot of a gift table (chicken eggs, sniffer seeds) with the + /// mob as `this` (`LivingEntity.dropFromGiftLootTable`). GiftLoot { + mob: u32, table: &'static str, at: [f64; 3], }, @@ -1151,6 +1198,18 @@ pub enum MobEvent { DigParticles { at: [f64; 3], }, + /// `Zombie.hurtServer`: a zombie hurt with a target in sight (its own, + /// else the attacker) may call a reinforcement against it on Hard. + Reinforcements { + mob: u32, + target: Who, + }, + /// `LevelEvent.PARTICLES_DESTROY_BLOCK` (2001) at `pos` for the block at + /// `block`: a turtle's digging. + BlockBreakEffect { + pos: [i32; 3], + block: [i32; 3], + }, /// `Warden.applyDarknessAround`: darkness for survival players nearby. Darkness { at: [f64; 3], @@ -1243,6 +1302,18 @@ pub struct Seen { /// `MAX_HEALTH`, `JUMP_STRENGTH` and `MOVEMENT_SPEED` base values (a /// horse's breeding stock). pub stats: [f64; 3], + /// `Wolf.isInSittingPose`. + pub sitting: bool, + /// `Entity.isVehicle` or `isPassenger`. + pub ridden: bool, + /// The active effects, without namespace (`slowness`, `poison`...). + pub effects: Vec<&'static str>, +} +impl Seen { + /// `LivingEntity.hasEffect`, by id without namespace. + pub fn has_effect(&self, effect: &str) -> bool { + self.effects.contains(&effect) + } } impl Mob { pub fn burning_time(&self) -> f64 { @@ -1315,6 +1386,14 @@ impl Mob { m.extra.vector[0], m.attrs.speed, ], + sitting: m.extra.sitting, + ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(), + effects: m + .extra + .effects + .iter() + .map(|e| e.0.trim_start_matches("minecraft:")) + .collect(), } } /// `EntityType.getCategory`'s name. @@ -1459,6 +1538,8 @@ pub struct PlayerView { pub yaw: f32, /// The Hero of the Village effect. pub hero: bool, + /// The active effects, without namespace (`slowness`, `poison`...). + pub effects: Vec<&'static str>, /// The player's UUID (villagers' gossip is keyed by it). pub uuid: [i32; 4], /// The mobs on the player's team (`isAlliedTo`): combat targeting @@ -1468,6 +1549,12 @@ pub struct PlayerView { /// (`EntitySelector.pushableBy` on the client). pub remote: bool, } +impl PlayerView { + /// `LivingEntity.hasEffect`, by id without namespace. + pub fn has_effect(&self, effect: &str) -> bool { + self.effects.contains(&effect) + } +} #[derive(Clone)] pub struct MobWorld<'a> { pub world: &'a World, @@ -1752,6 +1839,10 @@ impl Mob { self.health -= amount; self.hurt_time = 10; } + // Animal.actuallyHurt: damage taken ends love mode (`resetLove`). + if drops::is_animal(self.species()) { + self.extra.in_love = 0; + } self.last_damage = Some((source, i64::from(self.age))); if let Some(who) = source.attacker { self.ai.last_hurt_by = Some(who); @@ -1839,7 +1930,8 @@ impl Mob { /// Movement speed attribute bonus (baby zombies, sprinting modifiers). /// MOVEMENT_SPEED including effects (speed +20%/level, slowness -15%/level). pub fn movement_speed(&self) -> f64 { - let mut v = strider::movement_speed(self, self.attrs.speed) * self.speed_bonus(); + let mut v = strider::movement_speed(self, witch::speed(self, self.attrs.speed)) + * self.speed_bonus(); for (e, _, amp) in &self.extra.effects { match *e { "minecraft:speed" => v *= 1.0 + 0.2 * f64::from(amp + 1), diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index c9bc15fda..258c1ccc7 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -183,6 +183,9 @@ pub fn register(m: &mut Mob) { if m.species() == Species::Rabbit { super::rabbit::register(m); } + if m.species() == Species::Turtle { + super::turtle::register(m); + } } Family::Inert => {} Family::Witch => super::witch::register(m), @@ -712,6 +715,14 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & // setAge at the age boundary: refreshDimensions. if m.extra.age >= 0 { m.fudge_position_after_size_change(w, bw, bh); + // Turtle.ageBoundaryReached: a grown turtle drops a scute. + if m.species() == Species::Turtle { + events.push(MobEvent::GiftLoot { + mob: m.id, + table: "minecraft:gameplay/turtle_grow", + at: [m.position.x, m.position.y, m.position.z], + }); + } } } else if m.extra.age > 0 { m.extra.age -= 1; @@ -746,7 +757,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() { m.ignite_for_ticks(160); } - species_ai_step(m, w, events); + species_ai_step(m, w, level_random, events); } impl Mob { /// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`): @@ -944,6 +955,7 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { | Species::Parched | Species::WitherSkeleton | Species::ZombifiedPiglin => undead::custom_server_ai_step(m, cx), + Species::Vindicator => super::illager::vindicator_server_ai_step(m), Species::Enderman => super::enderman::custom_server_ai_step(m, cx), Species::Blaze => super::nether::blaze_server_ai_step(m, cx), Species::ElderGuardian => super::guardian::elder_server_ai_step(m, cx), @@ -955,7 +967,12 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { _ => {} } } -fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { +fn species_ai_step( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { if family(m.species()) == Family::Aquatic { aquatic::ai_step(m, w, events); } @@ -988,7 +1005,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } Species::Shulker => { super::shulker::tick(m, w, events); - super::shulker::ai_step(m, w, events); + super::shulker::ai_step(m, w, level_random, events); } Species::SnowGolem => super::golem::snow_ai_step(m, w, events), Species::CopperGolem => super::copper_golem::ai_step(m, w, events), @@ -999,6 +1016,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events), Species::Chicken => farm::chicken_ai_step(m, w, events), + Species::Turtle => super::turtle::ai_step(m, w, events), Species::Sheep if m.extra.anim_ticks > 0 => { m.extra.anim_ticks -= 1; } @@ -1024,6 +1042,10 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool { if m.species() == Species::Mannequin { return true; } + // Mob.checkDespawn discards disallowed types even when persistent or NoAI. + if w.difficulty.effective == 0.0 && !super::spawn_rules::allowed_in_peaceful(m.species()) { + return false; + } let Some(p) = w.nearest_player else { return true; }; @@ -1238,6 +1260,20 @@ pub fn walk_target_value(m: &Mob, w: &MobWorld, p: [i32; 3]) -> f32 { if m.species() == Species::Strider { return super::strider::walk_target_value(m, w, p); } + // Hoglin.getWalkTargetValue: nowhere near a repellent, crimson nylium best. + if m.species() == Species::Hoglin { + let near = m.extra.repellent.is_some_and(|r| { + let d = |i: usize| i64::from(r[i] - p[i]); + d(0) * d(0) + d(1) * d(1) + d(2) * d(2) < 64 + }); + return if near { + -1.0 + } else if w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:crimson_nylium" { + 10.0 + } else { + 0.0 + }; + } // Bee.getWalkTargetValue: any air block. if m.species() == Species::Bee { return if w.facts.is_air(w.world.block(p[0], p[1], p[2])) { @@ -1306,6 +1342,11 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) { Species::Warden => super::warden::hurt(m, source), Species::Wolf => super::tame::after_hurt(m, source), Species::Nautilus | Species::ZombieNautilus => super::nautilus::after_hurt(m, source), + Species::Zombie + | Species::Husk + | Species::Drowned + | Species::ZombieVillager + | Species::ZombifiedPiglin => super::undead::after_hurt(m, source), // NeutralMob: an iron golem hit by the player stays angry a while. // PiglinAi.wasHurtBy: drop what is being admired, no admiring for // a while after a player's hit, and anger. diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index b2158132b..b81f224ae 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -102,7 +102,7 @@ impl AnimState { pub fn end_tick(&mut self, _swinging: bool) {} } /// Part poses for one draw, keyed by part name. -#[derive(Clone)] +#[derive(Clone, Default)] pub struct Poses { map: HashMap, dummy: [f32; 9], @@ -113,20 +113,24 @@ pub struct Poses { } impl Poses { pub fn new(model: &Part) -> Self { - let mut map = HashMap::new(); + let mut poses = Self::default(); + poses.reset(model); + poses + } + /// Refills this for another model, keeping the map and the sets' capacity: + /// a mob draws its body and every equipment layer each frame. + pub fn reset(&mut self, model: &Part) { fn walk(p: &Part, map: &mut HashMap) { map.insert(p.name.clone(), p.pose); for c in &p.children { walk(c, map); } } - walk(model, &mut map); - Self { - map, - dummy: [0.0; 9], - skipped: Default::default(), - hidden: Default::default(), - } + self.map.clear(); + self.dummy = [0.0; 9]; + self.skipped.clear(); + self.hidden.clear(); + walk(model, &mut self.map); } pub fn get(&mut self, name: &str) -> &mut [f32; 9] { match self.map.get_mut(name) { @@ -189,14 +193,15 @@ pub fn state(m: &Mob, alpha: f32) -> State { swing += 1.0; } let swing = m.anim.attack_anim_o + swing * alpha; - let alive = m.alive(); + // LivingEntityRenderer.extractRenderState: riding motion is not walking. + let walking = m.alive() && !m.extra.passenger; State { - pos: if alive { + pos: if walking { m.limb_position - m.limb_speed * (1.0 - alpha) } else { 0.0 }, - speed: if alive { + speed: if walking { (m.previous_limb_speed + (m.limb_speed - m.previous_limb_speed) * alpha).min(1.0) } else { 0.0 diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index dc80c6ea4..3f86d8edc 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -234,12 +234,40 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } /// Squid.aiStep: tentacle cycle, propulsion and body rotation. fn squid_ai_step(m: &mut Mob) { + squid_step(m, false); +} + +/// Squid.aiStep also runs on remote entities: animate from packet velocity, +/// without running goals or replacing that velocity with the server's movement vector. +pub fn squid_client_step(m: &mut Mob) { + if matches!(m.species(), Species::Squid | Species::GlowSquid) { + m.anim.vo = m.anim.v; + squid_step(m, true); + } +} + +/// Squid.handleEntityEvent(19): begin the next server-synchronised tentacle cycle. +pub fn squid_cycle_event(m: &mut Mob) { + if matches!(m.species(), Species::Squid | Species::GlowSquid) { + m.anim.v[TENTACLE_MOVEMENT] = 0.0; + } +} + +fn squid_step(m: &mut Mob, client: bool) { let v = &mut m.anim.v; v[TENTACLE_MOVEMENT] += v[TENTACLE_SPEED]; - if v[TENTACLE_MOVEMENT] > PI * 2.0 { - v[TENTACLE_MOVEMENT] -= PI * 2.0; - if m.random.next_int_bound(10).unwrap_or(1) == 0 { - m.anim.v[TENTACLE_SPEED] = 1.0 / (m.random.next_float() + 1.0) * 0.2; + if f64::from(v[TENTACLE_MOVEMENT]) > std::f64::consts::TAU { + if client { + v[TENTACLE_MOVEMENT] = PI * 2.0; + } else { + v[TENTACLE_MOVEMENT] -= PI * 2.0; + if m.random.next_int_bound(10).unwrap_or(1) == 0 { + m.anim.v[TENTACLE_SPEED] = 1.0 / (m.random.next_float() + 1.0) * 0.2; + } + m.outbox.push(MobEvent::SquidCycle { + mob: m.id, + at: [m.position.x, m.position.y, m.position.z], + }); } } let v = &mut m.anim.v; @@ -248,28 +276,39 @@ fn squid_ai_step(m: &mut Mob) { let scale = v[TENTACLE_MOVEMENT] / PI; v[TENTACLE_ANGLE] = (scale * scale * PI).msin() * PI * 0.25; if scale > 0.75 { - let mv = m.extra.vector; - m.velocity = Vec3::new(mv[0], mv[1], mv[2]); + if !client { + let mv = m.extra.vector; + m.velocity = Vec3::new(mv[0], mv[1], mv[2]); + } m.anim.v[ROTATE_SPEED] = 1.0; } else { m.anim.v[ROTATE_SPEED] *= 0.8; } } else { v[TENTACLE_ANGLE] = 0.0; - m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9); + if !client { + m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9); + } m.anim.v[ROTATE_SPEED] *= 0.99; } let mv = m.velocity; let horizontal = (mv.x * mv.x + mv.z * mv.z).sqrt(); - m.body_yaw += (-(mv.x.atan2(mv.z) as f32).to_degrees() - m.body_yaw) * 0.1; + m.body_yaw += (-(horizon_core::geometry::trig::atan2(mv.x, mv.z) as f32) * 57.295776 + - m.body_yaw) + * 0.1; m.yaw = m.body_yaw; let v = &mut m.anim.v; v[Z_BODY_ROT] += PI * v[ROTATE_SPEED] * 1.5; - v[X_BODY_ROT] += (-(horizontal.atan2(mv.y) as f32).to_degrees() - v[X_BODY_ROT]) * 0.1; + v[X_BODY_ROT] += (-(horizon_core::geometry::trig::atan2(horizontal, mv.y) as f32) + * 57.295776 + - v[X_BODY_ROT]) + * 0.1; } else { v[TENTACLE_ANGLE] = v[TENTACLE_MOVEMENT].msin().abs() * PI * 0.25; - let yd = m.velocity.y - m.kind.gravity; - m.velocity = Vec3::new(0.0, yd * 0.98, 0.0); + if !client { + let yd = m.velocity.y - m.kind.gravity; + m.velocity = Vec3::new(0.0, yd * 0.98, 0.0); + } let v = &mut m.anim.v; v[X_BODY_ROT] += (-90.0 - v[X_BODY_ROT]) * 0.02; } diff --git a/crates/horizon-client/src/mobs/bat.rs b/crates/horizon-client/src/mobs/bat.rs index 9f38e9e60..ca5efa4a2 100644 --- a/crates/horizon-client/src/mobs/bat.rs +++ b/crates/horizon-client/src/mobs/bat.rs @@ -132,6 +132,10 @@ pub fn tick(m: &mut Mob) { } else { m.velocity.y *= 0.6; } + setup_animation_states(m); +} +/// `Bat.setupAnimationStates`: client visuals, without changing server motion. +pub fn setup_animation_states(m: &mut Mob) { let age = m.age as f32; if resting(m) { m.anim.states[0].stop(); diff --git a/crates/horizon-client/src/mobs/body_anim.rs b/crates/horizon-client/src/mobs/body_anim.rs index 4411d347b..bbb44c5ab 100644 --- a/crates/horizon-client/src/mobs/body_anim.rs +++ b/crates/horizon-client/src/mobs/body_anim.rs @@ -969,8 +969,8 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool { s, &Turtle { on_land: !m.fluids.in_water && m.on_ground, - laying_egg: false, - has_egg: false, + laying_egg: m.extra.flag2, + has_egg: m.extra.flag, }, ), Species::Strider => strider(p, s, m.rider.is_some()), diff --git a/crates/horizon-client/src/mobs/doors.rs b/crates/horizon-client/src/mobs/doors.rs new file mode 100644 index 000000000..a55ab9863 --- /dev/null +++ b/crates/horizon-client/src/mobs/doors.rs @@ -0,0 +1,252 @@ +//! Doors a mob gets through on its own: `DoorInteractGoal` with its two +//! users, `BreakDoorGoal` (zombies that can break doors, vindicators in a +//! raid) and `OpenDoorGoal` (`AbstractIllager.RaiderOpenDoorGoal`). +use super::{ + Mob, MobEvent, MobWorld, Species, + goals::{Cx, Goal, MOVE, goal_clone, reduced}, + nav::NavKind, + raider, +}; +use horizon_core::{geometry::Vec3, random::RandomSource}; + +/// `DefaultDoorBreakTime`: `BreakDoorGoal.getDoorBreakTime` is +/// `max(240, doorBreakTime)`, and the vindicator's 6 is below it. +const DOOR_BREAK_TIME: i32 = 240; + +/// `GoalUtils.hasGroundPathNavigation`: ground, wall-climbing and +/// amphibious navigations walk. +pub fn can_navigate_ground(m: &Mob) -> bool { + matches!( + m.ai.nav.kind, + NavKind::Ground | NavKind::WallClimber | NavKind::Amphibious + ) +} +/// `Zombie.setCanBreakDoors`: only a mob that walks can break doors, and +/// then its navigation opens them. +pub fn set_can_break_doors(m: &mut Mob, can_break: bool) { + let can_break = can_break && can_navigate_ground(m); + m.extra.can_break_doors = can_break; + m.ai.nav.can_open_doors = can_break; +} +/// The species that extend `Zombie` (`CanBreakDoors` is theirs to save). +pub fn is_zombie(s: Species) -> bool { + matches!( + s, + Species::Zombie + | Species::Husk + | Species::Drowned + | Species::ZombieVillager + | Species::ZombifiedPiglin + ) +} +/// `DoorBlock.isWoodenDoor`: a door a hand opens, which is any but iron. +fn wooden_door(w: &MobWorld, p: [i32; 3]) -> bool { + let name = w.block_name(p[0], p[1], p[2]); + name.ends_with("_door") && name != "minecraft:iron_door" +} +/// `DoorInteractGoal`'s door: where it is and whether one is there. +#[derive(Clone, Debug, Default)] +struct Interact { + door: [i32; 3], + has_door: bool, +} +impl Interact { + /// `DoorInteractGoal.canUse`: the mob bumped into something and a + /// wooden door is on its path within 1.5 blocks, or at its own head. + fn can_use(&mut self, m: &Mob, w: &MobWorld) -> bool { + if !can_navigate_ground(m) || !m.horizontal_collision { + return false; + } + let Some(path) = m.ai.nav.path.as_ref().filter(|p| !p.is_done()) else { + return false; + }; + for node in path + .nodes + .iter() + .take((path.next + 2).min(path.nodes.len())) + { + self.door = [node.x, node.y + 1, node.z]; + let dx = m.position.x - f64::from(node.x); + let dz = m.position.z - f64::from(node.z); + if dx * dx + dz * dz <= 2.25 { + self.has_door = wooden_door(w, self.door); + if self.has_door { + return true; + } + } + } + let at = m.position; + self.door = [ + at.x.floor() as i32, + at.y.floor() as i32 + 1, + at.z.floor() as i32, + ]; + self.has_door = wooden_door(w, self.door); + self.has_door + } + /// `DoorInteractGoal.isOpen`: the door is still there and open. + fn is_open(&mut self, w: &MobWorld) -> bool { + if !self.has_door { + return false; + } + let d = self.door; + if !w.block_name(d[0], d[1], d[2]).ends_with("_door") { + self.has_door = false; + return false; + } + w.prop(d[0], d[1], d[2], "open") == Some("true") + } + /// `DoorInteractGoal.setOpen`. + fn set_open(&self, cx: &mut Cx, open: bool) { + let d = self.door; + if self.has_door && cx.w.block_name(d[0], d[1], d[2]).ends_with("_door") { + cx.events.push(MobEvent::SetDoorOpen { pos: d, open }); + } + } +} +/// `BlockPos.closerToCenterThan`. +fn closer_to_center(p: [i32; 3], at: Vec3, distance: f64) -> bool { + Vec3::new( + f64::from(p[0]) + 0.5, + f64::from(p[1]) + 0.5, + f64::from(p[2]) + 0.5, + ) + .distance_squared(at) + < distance * distance +} + +/// `BreakDoorGoal`: crack the door in the way for 240 ticks, then remove it. +/// A zombie does it on hard only; `Vindicator.VindicatorBreakDoorGoal` on +/// normal and hard, in a raid, and holds the mob's movement. +#[derive(Clone, Debug)] +pub struct BreakDoor { + interact: Interact, + vindicator: bool, + break_time: i32, + last_break_progress: i32, +} +impl BreakDoor { + /// `Zombie.breakDoorGoal`, added when the zombie can break doors. + pub fn zombie() -> Self { + Self::new(false) + } + /// `Vindicator.VindicatorBreakDoorGoal`. + pub fn vindicator() -> Self { + Self::new(true) + } + fn new(vindicator: bool) -> Self { + Self { + interact: Interact::default(), + vindicator, + break_time: 0, + last_break_progress: -1, + } + } + /// `DOOR_BREAKING_PREDICATE`. + fn valid_difficulty(&self, w: &MobWorld) -> bool { + if self.vindicator { + w.difficulty.normal || w.difficulty.hard + } else { + w.difficulty.hard + } + } + /// `BreakDoorGoal.canUse` after `DoorInteractGoal.canUse`. + fn can_break(&mut self, m: &Mob, w: &MobWorld) -> bool { + self.interact.can_use(m, w) + && w.mob_griefing + && self.valid_difficulty(w) + && !self.interact.is_open(w) + } +} +impl Goal for BreakDoor { + goal_clone!(); + fn flags(&self) -> u8 { + if self.vindicator { MOVE } else { 0 } + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + if self.vindicator { + return raider::has_active_raid(m) + && m.random.next_int_bound(reduced(10)).unwrap_or(1) == 0 + && self.can_break(m, cx.w); + } + m.extra.can_break_doors && self.can_break(m, cx.w) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + (!self.vindicator || raider::has_active_raid(m)) + && self.break_time <= DOOR_BREAK_TIME + && !self.interact.is_open(cx.w) + && closer_to_center(self.interact.door, m.position, 2.0) + && self.valid_difficulty(cx.w) + } + fn start(&mut self, m: &mut Mob, _cx: &mut Cx) { + self.break_time = 0; + if self.vindicator { + m.ai.no_action_time = 0; + } + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + cx.events.push(MobEvent::DestroyProgress { + mob: m.id, + pos: self.interact.door, + progress: -1, + }); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + let door = self.interact.door; + if m.random.next_int_bound(20).unwrap_or(1) == 0 { + cx.events.push(MobEvent::LevelEvent { + id: 1019, + pos: door, + data: 0, + }); + if !m.ai.swinging { + m.swing(); + } + } + self.break_time += 1; + let progress = (self.break_time as f32 / DOOR_BREAK_TIME as f32 * 10.0) as i32; + if progress != self.last_break_progress { + cx.events.push(MobEvent::DestroyProgress { + mob: m.id, + pos: door, + progress, + }); + self.last_break_progress = progress; + } + if self.break_time == DOOR_BREAK_TIME && self.valid_difficulty(cx.w) { + cx.events.push(MobEvent::BreakDoor { pos: door }); + } + } +} + +/// `AbstractIllager.RaiderOpenDoorGoal` (`OpenDoorGoal` that does not wait +/// to close): open the door in the way during a raid. It never continues, so +/// it closes the door again as soon as it stops. +#[derive(Clone, Debug, Default)] +pub struct RaiderOpenDoor { + interact: Interact, +} +impl Goal for RaiderOpenDoor { + goal_clone!(); + fn flags(&self) -> u8 { + 0 + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.interact.can_use(m, cx.w) && raider::has_active_raid(m) + } + fn can_continue(&mut self, _m: &mut Mob, _cx: &mut Cx) -> bool { + false + } + fn start(&mut self, _m: &mut Mob, cx: &mut Cx) { + self.interact.set_open(cx, true); + } + fn stop(&mut self, _m: &mut Mob, cx: &mut Cx) { + self.interact.set_open(cx, false); + } +} diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index e5ccfd283..064cf0583 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -304,6 +304,7 @@ fn loot_stack(item: &str, count: i32) -> LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), } } /// `Mob.dropChances.byEquipment(slot)`: the recorded chance, or the one the @@ -350,6 +351,9 @@ pub fn loot_entity(m: &Mob, vehicle: Option) -> LootEntity { .to_owned(), ); } + if s == S::Chicken { + e.chicken_variant = super::finalize::variant_component(m).map(|(_, id)| id); + } e.powered = s == S::Creeper && m.extra.charged; e.equipment = SLOTS .iter() @@ -455,6 +459,14 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec= m.extra.counter + 600 { + if cx.w.daylight && !cx.w.end && m.age as i32 >= m.extra.counter + 600 { let br = super::hostile::mob_brightness(m, cx.w); let (x, y, z) = ( m.position.x.floor() as i32, @@ -330,8 +331,11 @@ impl Goal for TakeBlock { fn flags(&self) -> u8 { 0 } - fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool { - m.extra.carried.is_none() && m.random.next_int_bound(reduced(20)).unwrap_or(1) == 0 + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + // EndermanTakeBlockGoal.canUse: needs mobGriefing, checked before the draw. + m.extra.carried.is_none() + && cx.w.mob_griefing + && m.random.next_int_bound(reduced(20)).unwrap_or(1) == 0 } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let x = (m.position.x - 2.0 + m.random.next_double() * 4.0).floor() as i32; @@ -378,8 +382,11 @@ impl Goal for LeaveBlock { fn flags(&self) -> u8 { 0 } - fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool { - m.extra.carried.is_some() && m.random.next_int_bound(reduced(2000)).unwrap_or(1) == 0 + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + // EndermanLeaveBlockGoal.canUse: needs mobGriefing, checked before the draw. + m.extra.carried.is_some() + && cx.w.mob_griefing + && m.random.next_int_bound(reduced(2000)).unwrap_or(1) == 0 } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let Some(carried) = m.extra.carried else { diff --git a/crates/horizon-client/src/mobs/farm.rs b/crates/horizon-client/src/mobs/farm.rs index 83d5ac1f5..87951b0e2 100644 --- a/crates/horizon-client/src/mobs/farm.rs +++ b/crates/horizon-client/src/mobs/farm.rs @@ -80,6 +80,7 @@ pub fn chicken_ai_step(m: &mut Mob, _w: &MobWorld, events: &mut Vec) { let at = [m.position.x, m.position.y, m.position.z]; events.push(MobEvent::Sound("entity.chicken.egg", at, 1.0, pitch)); events.push(MobEvent::GiftLoot { + mob: m.id, table: "minecraft:gameplay/chicken_lay", at, }); diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 4eeed885b..6e5daff1f 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -590,6 +590,14 @@ pub fn has_modifier(m: &Mob, a: Attr, id: &str) -> bool { .as_ref() .is_some_and(|s| s.modifiers.iter().any(|x| x.attr == a && x.id == id)) } +/// The amount of a permanent modifier, if the mob has it. +pub fn modifier_amount(m: &Mob, a: Attr, id: &str) -> Option { + m.extra + .attributes + .as_ref() + .and_then(|s| s.modifiers.iter().find(|x| x.attr == a && x.id == id)) + .map(|x| x.amount) +} /// `addOrReplacePermanentModifier`. pub fn add_modifier(m: &mut Mob, a: Attr, id: &'static str, amount: f64, op: Op) { let s = m.extra.attributes.get_or_insert_with(Box::default); @@ -605,6 +613,59 @@ pub fn add_modifier(m: &mut Mob, a: Attr, id: &'static str, amount: f64, op: Op) } refresh(m, a); } +/// `Zombie.handleAttributes`, from the zombie's own random. +pub fn zombie_attributes(m: &mut Mob, special: f32, reason: Reason) { + let r = &mut m.random; + let reinforcements = if m.kind.species == Species::ZombifiedPiglin { + 0.0 + } else { + r.next_double() * f64::from(0.1f32) + }; + let knockback = r.next_double() * f64::from(0.05f32); + let follow = r.next_double() * 1.5 * f64::from(special); + let leader = r.next_float() < special * 0.05; + let leader_rolls = leader.then(|| (r.next_double() * 0.25 + 0.5, r.next_double() * 3.0 + 1.0)); + set_base(m, Attr::SpawnReinforcements, reinforcements); + add_modifier( + m, + Attr::KnockbackResistance, + "minecraft:random_spawn_bonus", + knockback, + Op::AddValue, + ); + if follow > 1.0 { + add_modifier( + m, + Attr::FollowRange, + "minecraft:zombie_random_spawn_bonus", + follow, + Op::AddMultipliedTotal, + ); + } + if let Some((reinforce, health)) = leader_rolls { + add_modifier( + m, + Attr::SpawnReinforcements, + "minecraft:leader_zombie_bonus", + reinforce, + Op::AddValue, + ); + add_modifier( + m, + Attr::MaxHealth, + "minecraft:leader_zombie_bonus", + health, + Op::AddMultipliedTotal, + ); + if !matches!( + reason, + Reason::Conversion | Reason::Load | Reason::DimensionTravel + ) { + m.health = max_health(m); + } + super::doors::set_can_break_doors(m, true); + } +} pub fn max_health(m: &Mob) -> f32 { value(m, Attr::MaxHealth) as f32 } @@ -829,6 +890,14 @@ pub fn set_item(m: &mut Mob, slot: usize, id: &str) { } gear::set(m, gear_slot(slot), Vec::new()); } +/// The enchantments on what `m` has in `slot`. +pub fn slot_enchantments(m: &Mob, slot: usize) -> gear::Levels { + gear::gear(m).map_or_else(Vec::new, |g| g.slots[gear_slot(slot)].clone()) +} +/// Replace the enchantments of what `m` has in `slot` (after `set_item`). +pub fn set_slot_enchantments(m: &mut Mob, slot: usize, levels: gear::Levels) { + gear::set(m, gear_slot(slot), levels); +} /// `Mob.setDropChance`. pub fn set_drop_chance(m: &mut Mob, slot: usize, chance: f32) { let c = m @@ -1144,70 +1213,17 @@ impl Ctx<'_, L, R> { } } } - m.extra.can_break_doors = self.float() < special * 0.1; + let can_break_doors = self.float() < special * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); if reason != Reason::Conversion { self.populate(m); self.enchant(m); } } self.halloween(m); - self.zombie_attributes(m, special, reason); + zombie_attributes(m, special, reason); g } - /// `Zombie.handleAttributes`, from the zombie's own random. - fn zombie_attributes(&mut self, m: &mut Mob, special: f32, reason: Reason) { - let r = &mut m.random; - let reinforcements = if m.kind.species == Species::ZombifiedPiglin { - 0.0 - } else { - r.next_double() * f64::from(0.1f32) - }; - let knockback = r.next_double() * f64::from(0.05f32); - let follow = r.next_double() * 1.5 * f64::from(special); - let leader = r.next_float() < special * 0.05; - let leader_rolls = - leader.then(|| (r.next_double() * 0.25 + 0.5, r.next_double() * 3.0 + 1.0)); - set_base(m, Attr::SpawnReinforcements, reinforcements); - add_modifier( - m, - Attr::KnockbackResistance, - "minecraft:random_spawn_bonus", - knockback, - Op::AddValue, - ); - if follow > 1.0 { - add_modifier( - m, - Attr::FollowRange, - "minecraft:zombie_random_spawn_bonus", - follow, - Op::AddMultipliedTotal, - ); - } - if let Some((reinforce, health)) = leader_rolls { - add_modifier( - m, - Attr::SpawnReinforcements, - "minecraft:leader_zombie_bonus", - reinforce, - Op::AddValue, - ); - add_modifier( - m, - Attr::MaxHealth, - "minecraft:leader_zombie_bonus", - health, - Op::AddMultipliedTotal, - ); - if !matches!( - reason, - Reason::Conversion | Reason::Load | Reason::DimensionTravel - ) { - m.health = max_health(m); - } - m.extra.can_break_doors = true; - } - } fn husk(&mut self, m: &mut Mob, g: Option, reason: Reason) -> Option { let mut g = self.zombie(m, g, reason); if reason != Reason::Conversion { diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 7f73e32c4..541398bc5 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -1210,6 +1210,9 @@ pub struct NearestTarget { pub interval: i32, /// Extra condition on the attacker, checked after the random interval. pub when: Option bool>, + /// Mobs closer than this squared distance are not picked + /// (`GuardianAttackSelector`: `distanceToSqr > 9`). + pub beyond_sqr: Option, /// A condition checked before anything else, by subclasses overriding /// `canUse` (SpiderTargetGoal: only in the dark). pub gate: Option bool>, @@ -1242,6 +1245,7 @@ impl NearestTarget { must_reach: false, interval: reduced(10), when: None, + beyond_sqr: None, gate: None, label: None, found: None, @@ -1274,6 +1278,10 @@ impl NearestTarget { self.when = Some(f); self } + pub fn beyond(mut self, distance_sqr: f64) -> Self { + self.beyond_sqr = Some(distance_sqr); + self + } pub fn gate(mut self, f: fn(&Mob, &MobWorld) -> bool) -> Self { self.gate = Some(f); self @@ -1284,9 +1292,25 @@ impl NearestTarget { self } } +/// `AbstractIllager.canAttack`: a baby `AbstractVillager` is never attacked. +fn illager_spares(attacker: Species, target: &Seen) -> bool { + matches!( + attacker, + Species::Pillager | Species::Vindicator | Species::Evoker | Species::Illusioner + ) && target.baby + && matches!(target.species, Species::Villager | Species::WanderingTrader) +} /// TargetGoal.canContinueToUse pub fn target_continue(st: &mut TargetState, must_see: bool, m: &mut Mob, cx: &mut Cx) -> bool { let Some(who) = m.ai.target else { return false }; + if let Who::Mob(id) = who + && cx + .w + .seen(id) + .is_some_and(|s| illager_spares(m.species(), s)) + { + return false; + } let Some(t) = cx.w.target_info(who) else { return false; }; @@ -1345,10 +1369,12 @@ impl Goal for NearestTarget { prey => { let pos = Vec3::new(m.position.x, m.eye_y(), m.position.z); let id = m.id; + let attacker = m.species(); let candidates: Vec<(u32, Vec3)> = cx.w.nearby(pos, range, move |s| { s.id != id && !s.playing_dead + && !illager_spares(attacker, s) && match prey { Prey::Species(list) => list.contains(&s.species), Prey::Filter(f) => f(s), @@ -1361,6 +1387,9 @@ impl Goal for NearestTarget { if m.distance_sqr(cpos) > range * range { continue; } + if self.beyond_sqr.is_some_and(|d| m.distance_sqr(cpos) <= d) { + continue; + } if !m.sees(cx.w, Who::Mob(cid)) { continue; } @@ -1762,6 +1791,10 @@ impl Goal for Breed { if tamable && (!m.extra.tame || m.extra.sitting) { return false; } + // TurtleBreedGoal.canUse: not while carrying an egg. + if species == Species::Turtle && super::turtle::has_egg(m) { + return false; + } let id = m.id; // Horses and donkeys breed together (AbstractHorse.canParent: tame). // Sniffer.canMate: calm states only. @@ -1771,11 +1804,16 @@ impl Goal for Breed { } let llama = super::llama::is_llama(species); let equine = matches!(species, Species::Horse | Species::Donkey) || llama; - if equine && !m.extra.tame { - return false; - } - // Llama.canParent: not ridden, and at full health. - if llama && (m.rider.is_some() || m.health < m.attrs.max_health) { + // AbstractHorse.canParent: tame, an adult, not a vehicle or passenger + // and at full health. + if equine + && (!m.extra.tame + || m.is_baby() + || m.rider.is_some() + || m.extra.rider.is_some() + || m.extra.mount.is_some() + || m.health < m.attrs.max_health) + { return false; } self.partner = @@ -1788,7 +1826,10 @@ impl Goal for Breed { && s.in_love && !s.baby && (!tamable || s.tame) - && (!equine || s.tame) + // Wolf.canMate: a sitting wolf is no partner. + && !(species == Species::Wolf && s.sitting) + // AbstractHorse.canParent of the partner. + && (!equine || s.tame && !s.ridden && s.health >= s.stats[0] as f32) && (!sniffer || super::sniffer::can_mate_state(s.variant)) }) .map(|s| s.id) @@ -1832,6 +1873,10 @@ impl Goal for Breed { }); m.extra.in_love = 0; m.extra.age = 6000; + // TurtleBreedGoal.breed: the mother carries an egg, not a baby. + if m.species() == Species::Turtle { + m.extra.flag = true; + } cx.events.push(MobEvent::Breed { parent: m.id, partner: p.id, diff --git a/crates/horizon-client/src/mobs/guardian.rs b/crates/horizon-client/src/mobs/guardian.rs index d6219794d..880655361 100644 --- a/crates/horizon-client/src/mobs/guardian.rs +++ b/crates/horizon-client/src/mobs/guardian.rs @@ -61,7 +61,8 @@ pub fn register(m: &mut Mob) { NearestTarget::species( &[Species::Squid, Species::GlowSquid, Species::Axolotl], true, - ), + ) + .beyond(9.0), ); } /// `GuardianAttackGoal`. @@ -120,9 +121,11 @@ impl Goal for GuardianAttack { 1.0, )); } else if self.attack_time >= attack_duration(m) { - // Indirect magic (1, +2 for the elder; NORMAL difficulty), then + // Indirect magic (1, +2 on Hard, +2 for the elder), then // doHurtTarget. - let magic = if is_elder(m) { 3.0 } else { 1.0 }; + let magic = 1.0 + + if cx.w.difficulty.hard { 2.0 } else { 0.0 } + + if is_elder(m) { 2.0 } else { 0.0 }; let from = [m.position.x, m.position.y, m.position.z]; match who { Who::Player => cx.events.push(MobEvent::DamagePlayer { @@ -241,9 +244,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } else { a.v[SPIKES] += (1.0 - a.v[SPIKES]) * 0.06; } - if m.extra.flag && m.extra.counter2 < attack_duration(m) { - m.extra.counter2 += 1; - } + client_tick(m); if in_water { m.air = 300; } else if m.on_ground { @@ -332,6 +333,13 @@ pub fn elder_server_ai_step(m: &mut Mob, cx: &mut Cx) { ]); } } +/// Guardian.aiStep's visual attack clock, also used by server clients. +/// Does not run attack goals, movement, damage or random draws. +pub fn client_tick(m: &mut Mob) { + if m.extra.flag && m.extra.counter2 < attack_duration(m) { + m.extra.counter2 += 1; + } +} /// The beam's clock for rendering: (attack time with the partial tick, /// attack scale), while a target is locked. pub fn beam(m: &Mob, partial: f32) -> Option<(f32, f32)> { diff --git a/crates/horizon-client/src/mobs/hostile.rs b/crates/horizon-client/src/mobs/hostile.rs index 0d3feaa6c..f1c990c48 100644 --- a/crates/horizon-client/src/mobs/hostile.rs +++ b/crates/horizon-client/src/mobs/hostile.rs @@ -177,6 +177,10 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec) { if m.swell >= 30 { m.swell = 30; let radius = if m.extra.charged { 6.0 } else { 3.0 }; + events.push(MobEvent::ExplosionSource { + mob: m.id, + charged: m.extra.charged, + }); events.push(MobEvent::Explode( [m.position.x, m.position.y, m.position.z], radius, diff --git a/crates/horizon-client/src/mobs/illager.rs b/crates/horizon-client/src/mobs/illager.rs index 45ef076d3..be178a7b9 100644 --- a/crates/horizon-client/src/mobs/illager.rs +++ b/crates/horizon-client/src/mobs/illager.rs @@ -72,6 +72,8 @@ pub fn register_vindicator(m: &mut Mob) { let g = &mut m.ai.goals; g.add(0, Float); g.add(1, avoid_creaking(1.0, 1.2)); + g.add(2, super::doors::BreakDoor::vindicator()); + g.add(3, super::doors::RaiderOpenDoor::default()); g.add(4, super::raider::HoldGroundAttack::new(10.0)); g.add(5, MeleeAttack::new(1.0, false)); g.add(8, Stroll::new(0.6)); @@ -86,6 +88,13 @@ pub fn register_vindicator(m: &mut Mob) { .when(|m, _| m.custom_name.as_deref() == Some("Johnny")), ); } +/// `Vindicator.customServerAiStep`: its navigation opens doors while a raid +/// is at its position. +pub fn vindicator_server_ai_step(m: &mut Mob) { + if super::doors::can_navigate_ground(m) { + m.ai.nav.can_open_doors = super::raider::state(m).is_some_and(|s| s.raided); + } +} // Evoker state: `counter` spellCastingTickCount, `variant2` the spell // being cast (0 none, 1 vexes, 2 fangs, 3 wololo), `counter2` the wololo // sheep's id. @@ -179,8 +188,8 @@ impl Goal for UseSpell { fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { let age = i64::from(m.age); if self.spell == Spell::Wololo { - // EvokerWololoSpellGoal.canUse: a blue sheep within 16 (4 up or - // down), no target, griefing on. + // EvokerWololoSpellGoal.canUse: a blue sheep within the box + // (16 sideways, 4 up or down) and `range(16)`, no target, griefing on. if m.ai.target.is_some() || m.extra.counter > 0 || age < self.next || !cx.w.mob_griefing { return false; @@ -196,6 +205,7 @@ impl Goal for UseSpell { && (s.position.x - p.x).abs() <= 16.0 && (s.position.y - p.y).abs() <= 4.0 && (s.position.z - p.z).abs() <= 16.0 + && s.position.distance_squared(p) <= 16.0 * 16.0 }) .map(|s| s.id) .collect(); @@ -210,7 +220,8 @@ impl Goal for UseSpell { return false; } if self.spell == Spell::Vexes { - // EvokerSummonSpellGoal: fewer vexes nearby than a roll of 1..8. + // EvokerSummonSpellGoal: fewer vexes within `range(16)` than a + // roll of 1..8. let p = m.position; let vexes = cx.w.others @@ -218,7 +229,7 @@ impl Goal for UseSpell { .filter(|s| { s.species == Species::Vex && s.alive - && s.position.distance_squared(p) <= 16.0 * 16.0 * 3.0 + && s.position.distance_squared(p) <= 16.0 * 16.0 }) .count() as i32; return m.random.next_int_bound(8).unwrap_or(0) + 1 > vexes; diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index 410364097..de9ba42dd 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -9,12 +9,15 @@ use super::{ NearestTarget, Prey, RandomLookAround, Stroll, goal_clone, }, }; -use horizon_core::geometry::trig::Mth; +use horizon_core::geometry::{Vec3, trig::Mth}; use horizon_core::random::RandomSource; use std::f32::consts::PI; /// `PiglinAi.ADMIRE_DURATION`. pub const ADMIRE: i32 = 119; +/// `PiglinAi.eat`: the `ATE_RECENTLY` memory lasts 200 ticks (kept in +/// `extra.ints[0]` of a piglin). +pub const ATE_RECENTLY: i32 = 200; /// `AbstractPiglin.CONVERSION_TIME` (and the hoglin's). const CONVERSION: i32 = 300; @@ -45,6 +48,7 @@ pub fn register_piglin(m: &mut Mob) { g.add(3, super::illager::crossbow_goal(1.0, 8.0)); } g.add(3, MeleeAttack::new(1.0, true)); + g.add(4, PiglinAvoidRepellent); g.add(4, GoToWantedItem); g.add(5, Stroll::avoiding_water(0.6)); g.add(6, LookAtPlayer::new(8.0)); @@ -83,12 +87,16 @@ pub fn register_hoglin(m: &mut Mob) { g.add(1, BabyPanic(super::goals::Panic::new(1.25))); g.add(2, MeleeAttack::new(1.0, true)); g.add(3, super::goals::Breed::new(1.0)); + g.add(3, HoglinAvoidRepellent); g.add(4, super::goals::FollowParent::new(1.1)); g.add(5, Stroll::avoiding_water(0.4)); g.add(6, LookAtPlayer::new(8.0)); m.ai.targets.add(1, HurtByTarget::new().alert()); - m.ai.targets - .add(2, NearestTarget::player(true).when(|m, _| !m.is_baby())); + // `HoglinAi.findNearestValidAttackTarget`: nothing while pacified. + m.ai.targets.add( + 2, + NearestTarget::player(true).when(|m, _| !m.is_baby() && m.extra.ints[0] <= 0), + ); } pub fn register_zoglin(m: &mut Mob) { let g = &mut m.ai.goals; @@ -108,6 +116,49 @@ pub fn register_zoglin(m: &mut Mob) { .interval(10), ); } +/// `SetWalkTargetAwayFrom.pos(NEAREST_REPELLENT, 1.0, 8, true)` of the hoglin's +/// idle activity: closer than 8 blocks to a repellent, walk away from it. +#[derive(Clone, Debug)] +struct HoglinAvoidRepellent; +impl Goal for HoglinAvoidRepellent { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + let Some(r) = m.extra.repellent else { + return false; + }; + // Vec3.atBottomCenterOf. + let avoid = [ + f64::from(r[0]) + 0.5, + f64::from(r[1]), + f64::from(r[2]) + 0.5, + ]; + if m.distance_sqr(Vec3::new(avoid[0], avoid[1], avoid[2])) >= 64.0 { + return false; + } + for _ in 0..10 { + if let Some(p) = m.land_random_pos_away(cx.w, 16, 7, avoid) { + let at = [ + p[0].floor() as i32, + p[1].floor() as i32, + p[2].floor() as i32, + ]; + m.ai.pending_path = m.create_path_to(cx.w, at, 0); + return m.ai.pending_path.is_some(); + } + } + false + } + fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { + !m.ai.nav.is_done() + } + fn start(&mut self, m: &mut Mob, _: &mut Cx) { + let path = m.ai.pending_path.take(); + m.nav_move_to_path(path, 1.0); + } +} /// Baby hoglins flee what hurts them; adults stand their ground. #[derive(Clone, Debug)] struct BabyPanic(super::goals::Panic); @@ -129,6 +180,54 @@ impl Goal for BabyPanic { self.0.stop(m, cx) } } +/// `PiglinAi.avoidRepellent`: `SetWalkTargetAwayFrom.pos(NEAREST_REPELLENT, 1.0, 8, false)` +/// of the idle activity. Closer than 8 blocks to a repellent and not walking +/// anywhere, walk away from it. +#[derive(Clone, Debug)] +struct PiglinAvoidRepellent; +impl Goal for PiglinAvoidRepellent { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + let Some(r) = m.extra.repellent else { + return false; + }; + // The idle activity: not fighting or admiring, and no walk target yet. + if m.ai.target.is_some() || m.extra.using_item > 0 || !m.ai.nav.is_done() { + return false; + } + // Vec3.atBottomCenterOf. + let avoid = [ + f64::from(r[0]) + 0.5, + f64::from(r[1]), + f64::from(r[2]) + 0.5, + ]; + if m.distance_sqr(Vec3::new(avoid[0], avoid[1], avoid[2])) >= 64.0 { + return false; + } + for _ in 0..10 { + if let Some(p) = m.land_random_pos_away(cx.w, 16, 7, avoid) { + let at = [ + p[0].floor() as i32, + p[1].floor() as i32, + p[2].floor() as i32, + ]; + m.ai.pending_path = m.create_path_to(cx.w, at, 0); + return m.ai.pending_path.is_some(); + } + } + false + } + fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { + !m.ai.nav.is_done() + } + fn start(&mut self, m: &mut Mob, _: &mut Cx) { + let path = m.ai.pending_path.take(); + m.nav_move_to_path(path, 1.0); + } +} /// Admiring a gold item held in the off hand: stand and look at it. #[derive(Clone, Debug)] struct Admire; @@ -165,18 +264,54 @@ impl Goal for GoToWantedItem { m.extra.point = None; } } +/// `HoglinSpecificSensor.findNearestRepellent`: the nearest `#hoglin_repellents` +/// block within 8 blocks sideways and 4 up or down. +fn find_hoglin_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { + let tag = w.block_tags.get("minecraft:hoglin_repellents")?; + let at = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + w.nearest_block(at, 8, 4, |_, name| tag.contains(name)) +} +/// The hoglin's repellent memories: the sensor (every 20 ticks), the end of +/// `PACIFIED` (`ints[0]`), a hit ending it (`HoglinAi.wasHurtBy`, `ints[1]` +/// remembers the hit seen) and `BecomePassiveIfMemoryPresent(NEAREST_REPELLENT, 200)`. +fn hoglin_repellent(m: &mut Mob, w: &MobWorld) { + if m.extra.ints[0] > 0 { + m.extra.ints[0] -= 1; + } + if m.ai.last_hurt_by.is_some() && m.ai.last_hurt_by_time != m.extra.ints[1] { + m.extra.ints[1] = m.ai.last_hurt_by_time; + m.extra.ints[0] = 0; + } + if m.age.wrapping_add(m.id).is_multiple_of(20) { + m.extra.repellent = find_hoglin_repellent(m, w); + } + if m.extra.ints[0] <= 0 && m.extra.repellent.is_some() { + m.extra.ints[0] = 200; + m.ai.target = None; + } +} /// Per-tick piglin and hoglin state: admiring, anger, zombification and the /// hoglin attack animation. Returns a conversion when it happens. pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { if m.extra.anim_ticks > 0 { m.extra.anim_ticks -= 1; } + if m.species() == Species::Hoglin && m.alive() { + hoglin_repellent(m, w); + } if m.extra.anger > 0 { m.extra.anger -= 1; if m.extra.anger == 0 && m.ai.target == Some(Who::Player) && !hostile_to_player(m, w) { m.ai.target = None; } } + if m.species() == Species::Piglin && m.extra.ints[0] > 0 { + m.extra.ints[0] -= 1; + } // Admiring disabled after being hit by a player. if m.extra.egg_time > 0 { m.extra.egg_time -= 1; @@ -219,6 +354,26 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } else { m.extra.counter2 = 0; } + // PiglinSpecificSensor (every 20 ticks): the nearest repelling block. + if m.species() == Species::Piglin && m.alive() && m.age.wrapping_add(m.id).is_multiple_of(20) { + m.extra.repellent = find_piglin_repellent(m, w); + } +} +/// `PiglinSpecificSensor.findNearestRepellent`: the nearest `#piglin_repellents` +/// block within 8 blocks sideways and 4 up or down; a soul campfire only +/// counts while lit. +fn find_piglin_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { + let tag = w.block_tags.get("minecraft:piglin_repellents")?; + let at = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + w.nearest_block(at, 8, 4, |p, name| { + tag.contains(name) + && (name != "minecraft:soul_campfire" + || w.prop(p[0], p[1], p[2], "lit") == Some("true")) + }) } /// `HoglinBase.hurtAndThrowTarget`. pub fn hoglin_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) { diff --git a/crates/horizon-client/src/mobs/rabbit.rs b/crates/horizon-client/src/mobs/rabbit.rs index a2ab6e95c..ac4c3850f 100644 --- a/crates/horizon-client/src/mobs/rabbit.rs +++ b/crates/horizon-client/src/mobs/rabbit.rs @@ -1,5 +1,9 @@ //! Rabbit hopping controls and the clocks consumed by `RabbitModel`. -use super::{Mob, MobEvent, MobWorld, control::Operation}; +use super::{ + Mob, MobEvent, MobWorld, + control::Operation, + goals::{Breed, Tempt}, +}; use horizon_core::{ geometry::{Vec3, trig}, random::RandomSource, @@ -14,7 +18,12 @@ const IDLE_TIMEOUT: usize = 3; const CLIENT_JUMP_TICKS: usize = 4; const CLIENT_JUMP_DURATION: usize = 5; +/// Rabbit.registerGoals after the generic panic and stroll: BreedGoal and +/// TemptGoal with rabbit food. pub fn register(m: &mut Mob) { + let food = m.kind.food.clone(); + m.ai.goals.add(2, Breed::new(0.8)); + m.ai.goals.add(3, Tempt::new(1.0, food, false)); m.ai.mov.kind = super::control::MoveKind::Custom; m.ai.nav.can_float = true; m.extra.ints[IDLE_TIMEOUT] = m.random.next_int_bound(40).unwrap_or(0) + 180; diff --git a/crates/horizon-client/src/mobs/raider.rs b/crates/horizon-client/src/mobs/raider.rs index 6c3ab9b64..d2ed62374 100644 --- a/crates/horizon-client/src/mobs/raider.rs +++ b/crates/horizon-client/src/mobs/raider.rs @@ -30,6 +30,9 @@ pub struct State { pub celebrating: bool, /// The raid is active (`hasActiveRaid`); set by the game every tick. pub active_raid: bool, + /// A raid is active within 96 blocks (`ServerLevel.isRaided`); set by + /// the game every tick for vindicators. + pub raided: bool, /// The raid is won or lost (`Raid.isOver`), lost (`isLoss`). pub raid_over: bool, pub raid_loss: bool, diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index bbbf26738..077fd38a6 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -951,21 +951,25 @@ fn draw_normals( return; } let normals = normals.part(p); - for poly in part.polygons.iter().filter(|_| !poses.skips(part)) { - let normal = poly.vertex_normal(normals); - for v in poly.vertices { - let pos = m.transform_point3(glam::Vec3::new(v[0], v[1], v[2]) / 16.0); - out.push(Vertex { - normal, - pos: pos.to_array(), - uv: [ - uv[0] + (uv[2] - uv[0]) * v[3], - uv[1] + (uv[3] - uv[1]) * v[4], - ], - layer: overlay, - color, - light, - }); + // `skips` is a name lookup: ask once per part, not once per polygon. A part + // hidden this way still draws its children, so only the polygons are gated. + if !poses.skips(part) { + for poly in &part.polygons { + let normal = poly.vertex_normal(normals); + for v in poly.vertices { + let pos = m.transform_point3(glam::Vec3::new(v[0], v[1], v[2]) / 16.0); + out.push(Vertex { + normal, + pos: pos.to_array(), + uv: [ + uv[0] + (uv[2] - uv[0]) * v[3], + uv[1] + (uv[3] - uv[1]) * v[4], + ], + layer: overlay, + color, + light, + }); + } } } for c in &part.children { @@ -1094,6 +1098,9 @@ fn render_visible( } } } + // One Poses refilled for every layer (armour draws up to seven): it keeps + // its maps' capacity. + let mut layer_poses = Poses::default(); for layer in all_layers { let Some(part) = m.kind.layer(&layer.model).cloned() else { continue; @@ -1101,8 +1108,8 @@ fn render_visible( let Some(uv) = atlas(&layer.texture) else { continue; }; - let mut poses = Poses::new(&part); - anim::setup(m, &s, &mut poses, &data.animations); + layer_poses.reset(&part); + anim::setup(m, &s, &mut layer_poses, &data.animations); if matches!(m.species(), Species::Villager | Species::ZombieVillager) && let Some((folder, variant)) = layer .texture @@ -1113,7 +1120,7 @@ fn render_visible( data.villager_hats .read() .unwrap_or_else(std::sync::PoisonError::into_inner) - .apply(folder, variant, profession, &mut poses); + .apply(folder, variant, profession, &mut layer_poses); } let light = if layer.full_bright { 240 | 240 << 8 @@ -1128,7 +1135,16 @@ fn render_visible( None => overlay, }; let start = out.len(); - draw(&part, base, &poses, uv, layer.color, light, overlay, out); + draw( + &part, + base, + &layer_poses, + uv, + layer.color, + light, + overlay, + out, + ); if layer.full_bright && m.species() != Species::Warden { for vertex in &mut out[start..] { vertex.normal = [0; 4]; diff --git a/crates/horizon-client/src/mobs/shulker.rs b/crates/horizon-client/src/mobs/shulker.rs index 11ebdadb4..6f0edc2d7 100644 --- a/crates/horizon-client/src/mobs/shulker.rs +++ b/crates/horizon-client/src/mobs/shulker.rs @@ -3,7 +3,7 @@ //! their face is lost or when badly hurt, and shrugging off arrows while //! closed. use super::{ - Damage, DamageKind, Mob, MobEvent, MobWorld, Who, + Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who, anim::{Poses, State}, goals::{ Cx, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, NearestTarget, RandomLookAround, @@ -14,7 +14,7 @@ use crate::projectiles::{Projectile, ProjectileWorld, STEPS}; use horizon_core::geometry::trig::Mth; use horizon_core::{ geometry::{Aabb, Vec3}, - random::RandomSource, + random::{LegacyRandom, RandomSource}, }; use std::f32::consts::PI; @@ -164,12 +164,62 @@ pub fn after_hurt(m: &mut Mob) { m.extra.flag = true; } } -/// The deferred teleport of `after_hurt`. -pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { +/// `Shulker.hurtServer` after a shulker bullet's damage: unless the +/// low-health teleport was drawn instead (`else if`), `hitByShulkerBullet` +/// runs on the next tick. Whether the shell is open is read here, at hurt +/// time, so a goal opening it before that tick cannot change the outcome. +pub fn hit_by_bullet(m: &mut Mob) { + if m.species() == Species::Shulker && !m.extra.flag && m.extra.counter != 0 { + m.extra.flag2 = true; + } +} +/// The deferred teleport of `after_hurt` and `hitByShulkerBullet`. +pub fn ai_step( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { if m.extra.flag { m.extra.flag = false; teleport(m, w, events); } + if m.extra.flag2 { + m.extra.flag2 = false; + hit_by_shulker_bullet(m, w, level_random, events); + } +} +/// `Shulker.hitByShulkerBullet` (only queued by an open shulker): it +/// teleports, and leaves a new one of its colour behind unless shulkers +/// crowd the place (one in five fewer chances for every other shulker +/// within 8 blocks). +fn hit_by_shulker_bullet( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { + let old_position = m.position; + let old_box = m.bounds(); + if !teleport(m, w, events) { + return; + } + let area = old_box.inflate(Vec3::new(8.0, 8.0, 8.0)); + // The shulker itself stands within reach of its old box, so it counts. + let others = w + .others_near(area) + .filter(|s| { + s.alive && s.species == Species::Shulker && s.id != m.id && s.bounds.intersects(area) + }) + .count(); + let failure_chance = others as f32 / 5.0; + // The level's random, not the shulker's. + if level_random.next_float() < failure_chance { + return; + } + let mut baby = Mob::new(0, m.kind.clone(), old_position, 0.0); + baby.extra.color = m.extra.color; + events.push(MobEvent::Spawn(Box::new(baby))); } /// A ProjectileWorld over the mob world, for aiming a new bullet. struct Probe<'a, 'w> { diff --git a/crates/horizon-client/src/mobs/sniffer.rs b/crates/horizon-client/src/mobs/sniffer.rs index 6979171b8..c0c8d8155 100644 --- a/crates/horizon-client/src/mobs/sniffer.rs +++ b/crates/horizon-client/src/mobs/sniffer.rs @@ -94,6 +94,7 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec) { if current == DIGGING && now(m) == m.extra.ints[3] { let h = head_block(m); events.push(MobEvent::GiftLoot { + mob: m.id, table: "minecraft:gameplay/sniffer_digging", at: [f64::from(h[0]), f64::from(h[1]), f64::from(h[2])], }); diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 93e3fe119..58118d2c2 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -35,6 +35,8 @@ pub fn placement_ok(species: Species, w: &MobWorld, x: i32, y: i32, z: i32) -> b #[derive(Clone, Copy, Debug)] pub struct LocalDifficulty { pub effective: f32, + /// The world difficulty is normal. + pub normal: bool, /// The world difficulty is hard. pub hard: bool, } @@ -45,9 +47,11 @@ impl LocalDifficulty { /// the overworld clock, the chunk's inhabited time and the moon. pub fn new(base: u8, total: i64, inhabited: i64, moon: f32) -> Self { let hard = base == 3; + let normal = base == 2; if base == 0 { return Self { effective: 0.0, + normal, hard, }; } @@ -60,6 +64,7 @@ impl LocalDifficulty { } Self { effective: f32::from(base) * (0.75 + global + local), + normal, hard, } } @@ -148,6 +153,14 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { S::Pig | S::Cow | S::Chicken => m.extra.variant = climate_index, S::Sheep => m.extra.color = super::farm::sheep_color(biome.climate, &mut m.random), S::Mooshroom => m.extra.variant = 0, + // Turtle.finalizeSpawn: home is where it spawned. + S::Turtle => { + m.extra.home = Some([ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]) + } S::Frog => m.extra.variant = biome.frog, S::Wolf => m.extra.variant = biome.wolf, S::Cat => m.extra.variant = super::tame::cat_variant(m, biome.full_moon), @@ -158,6 +171,9 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { if m.random.next_float() < 0.05 { m.extra.age = i32::MIN / 2; } + // setCanBreakDoors(nextFloat() < special * 0.1), before the gear. + let can_break_doors = m.random.next_float() < difficulty.special() * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); if m.species() != S::Drowned { populate_armor(m, difficulty); } @@ -191,6 +207,9 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { // ZombifiedPiglin.populateDefaultEquipmentSlots: a golden sword, or // one time in 20 a golden spear. S::ZombifiedPiglin => { + // Zombie.finalizeSpawn's setCanBreakDoors. + let can_break_doors = m.random.next_float() < difficulty.special() * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); let spear = m.random.next_int_bound(20).unwrap_or(1) == 0; m.extra.main_hand = Some( if spear { @@ -264,6 +283,18 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { S::Slime | S::MagmaCube => super::cube::finalize(m), _ => {} } + // Zombie.finalizeSpawn ends with handleAttributes: the reinforcement + // chance (and a leader's bonus) that `Zombie.hurtServer` reads. + if matches!( + m.species(), + S::Zombie | S::Husk | S::Drowned | S::ZombieVillager | S::ZombifiedPiglin + ) { + super::finalize::zombie_attributes( + m, + difficulty.special(), + super::finalize::Reason::Natural, + ); + } } /// VillagerType.byBiome pub fn villager_type(b: &BiomeFacts) -> u8 { diff --git a/crates/horizon-client/src/mobs/turtle.rs b/crates/horizon-client/src/mobs/turtle.rs new file mode 100644 index 000000000..98dd5b558 --- /dev/null +++ b/crates/horizon-client/src/mobs/turtle.rs @@ -0,0 +1,209 @@ +//! Turtles (`Turtle`): the egg a bred mother carries (`TurtleBreedGoal`), her +//! walk home (`TurtleGoHomeGoal`) and the egg she digs into the sand +//! (`TurtleLayEggGoal`). `extra.flag` is `hasEgg`, `extra.flag2` `isLayingEgg`, +//! `extra.counter` `layEggCounter` and `extra.home` `homePos`. +use super::{ + Mob, MobEvent, MobWorld, + goals::{Breed, Cx, Goal, JUMP, MOVE, MoveToBlock, goal_clone, reduced}, +}; +use horizon_core::random::RandomSource; + +pub fn register(m: &mut Mob) { + let g = &mut m.ai.goals; + g.add(1, Breed::new(1.0)); + g.add(1, LayEgg(MoveToBlock::new(1.0, 16, 1))); + g.add(4, GoHome::default()); +} +/// `Turtle.hasEgg`. +pub fn has_egg(m: &Mob) -> bool { + m.extra.flag +} +/// `Turtle.setLayingEgg`. +fn set_laying_egg(m: &mut Mob, on: bool) { + m.extra.counter = i32::from(on); + m.extra.flag2 = on; +} +/// `homePos.closerToCenterThan(position, distance)`. Spawning, hatching and +/// loading all set a home; one made any other way is at home where it stands. +fn close_to_home(m: &Mob, distance: f64) -> bool { + let home = m.extra.home.unwrap_or([ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]); + let d = [ + f64::from(home[0]) + 0.5 - m.position.x, + f64::from(home[1]) + 0.5 - m.position.y, + f64::from(home[2]) + 0.5 - m.position.z, + ]; + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < distance * distance +} +fn block_of(m: &Mob) -> [i32; 3] { + [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ] +} +/// `TurtleEggBlock.isSand`. +fn is_sand(w: &MobWorld, p: [i32; 3]) -> bool { + w.has_tag("minecraft:sand", w.block_name(p[0], p[1], p[2])) +} +/// `Turtle.aiStep`: while digging, sand crumbs fly every fifth tick. +pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + if m.alive() && m.extra.flag2 && m.extra.counter >= 1 && m.extra.counter % 5 == 0 { + let at = block_of(m); + let below = [at[0], at[1] - 1, at[2]]; + if is_sand(w, below) { + events.push(MobEvent::BlockBreakEffect { + pos: at, + block: below, + }); + } + } +} + +/// `Turtle.TurtleLayEggGoal`: a mother with an egg within 9 blocks of home +/// walks to sand with air above, digs for 200 ticks and lays it. +#[derive(Clone, Debug)] +struct LayEgg(MoveToBlock); +impl LayEgg { + /// `isValidTarget`: sand with air above it. + fn valid(w: &MobWorld, p: [i32; 3]) -> bool { + w.facts.is_air(w.world.block(p[0], p[1] + 1, p[2])) && is_sand(w, p) + } +} +impl Goal for LayEgg { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE | JUMP + } + fn name(&self) -> &'static str { + "TurtleLayEggGoal" + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + if !(has_egg(m) && close_to_home(m, 9.0)) { + return false; + } + // MoveToBlockGoal.canUse. + if self.0.next_start > 0 { + self.0.next_start -= 1; + return false; + } + self.0.next_start = reduced(200 + m.random.next_int_bound(200).unwrap_or(0)); + self.0.find_nearest(m, cx.w, Self::valid) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.0.can_continue(cx.w, Self::valid) && has_egg(m) && close_to_home(m, 9.0) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.start(m, cx.w); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.tick(m, cx.w); + if m.fluids.in_water || !self.0.reached { + return; + } + if m.extra.counter < 1 { + set_laying_egg(m, true); + } else if m.extra.counter > 200 { + let at = [m.position.x, m.position.y, m.position.z]; + let pitch = 0.9 + m.random.next_float() * 0.2; + cx.events + .push(MobEvent::Sound("entity.turtle.lay_egg", at, 0.3, pitch)); + // Blocks.TURTLE_EGG with EGGS = nextInt(4) + 1 above the sand. + let eggs = m.random.next_int_bound(4).unwrap_or(0) + 1; + let registry = cx.w.registry; + let state = registry + .default_state("minecraft:turtle_egg") + .and_then(|d| registry.state(d).cloned()) + .and_then(|mut s| { + s.properties.insert("eggs".into(), eggs.to_string()); + registry.resolve(&s).ok() + }); + let b = self.0.block; + if let Some(state) = state { + cx.events.push(MobEvent::SetBlock { + pos: [b[0], b[1] + 1, b[2]], + state, + }); + } + m.extra.flag = false; + set_laying_egg(m, false); + m.extra.in_love = 600; + } + if m.extra.flag2 { + m.extra.counter += 1; + } + } +} + +/// `Turtle.TurtleGoHomeGoal`: a mother with an egg heads home; any adult +/// that strayed 64 blocks away does now and then. +#[derive(Clone, Debug, Default)] +struct GoHome { + stuck: bool, + close_to_home_try_ticks: i32, +} +impl Goal for GoHome { + goal_clone!(); + fn flags(&self) -> u8 { + 0 + } + fn name(&self) -> &'static str { + "TurtleGoHomeGoal" + } + fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { + if m.is_baby() { + false + } else if has_egg(m) { + true + } else { + m.random.next_int_bound(reduced(700)).unwrap_or(1) == 0 && !close_to_home(m, 64.0) + } + } + fn start(&mut self, _m: &mut Mob, _cx: &mut Cx) { + self.stuck = false; + self.close_to_home_try_ticks = 0; + } + fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { + !close_to_home(m, 7.0) && !self.stuck && self.close_to_home_try_ticks <= reduced(600) + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + let home = m.extra.home.unwrap_or(block_of(m)); + let close = close_to_home(m, 16.0); + if close { + self.close_to_home_try_ticks += 1; + } + if !m.ai.nav.is_done() { + return; + } + let to = [ + f64::from(home[0]) + 0.5, + f64::from(home[1]), + f64::from(home[2]) + 0.5, + ]; + let mut next = m + .default_random_pos_towards(cx.w, 16, 3, to, std::f64::consts::PI / 10.0) + .or_else(|| m.default_random_pos_towards(cx.w, 8, 7, to, std::f64::consts::FRAC_PI_2)); + if let Some(at) = next + && !close + && cx.w.block_name( + at[0].floor() as i32, + at[1].floor() as i32, + at[2].floor() as i32, + ) != "minecraft:water" + { + next = m.default_random_pos_towards(cx.w, 16, 5, to, std::f64::consts::FRAC_PI_2); + } + match next { + Some(at) => { + m.nav_move_to(cx.w, at, 1.0); + } + None => self.stuck = true, + } + } +} diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index e77a37641..52efea3c4 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -1,7 +1,8 @@ //! Zombies (zombie, husk, drowned, zombie villager) and skeletons (skeleton, //! stray, bogged, wither skeleton): goals, bow attacks, water conversion. use super::{ - Mob, MobEvent, Species, Who, + Damage, Mob, MobEvent, Species, Who, + doors::BreakDoor, goals::{ Avoid, AvoidEntity, Cx, FleeSun, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, MeleeAttack, NearestTarget, RandomLookAround, RestrictSun, Stroll, goal_clone, @@ -10,11 +11,22 @@ use super::{ use crate::projectiles::{Projectile, ProjectileKind}; use horizon_core::{geometry::Vec3, random::RandomSource}; +/// `Zombie.hurtServer`, once the hit landed: a zombie with a target (its own, +/// else a living attacker) asks the game to try calling a reinforcement; the +/// difficulty, chance and spawn tests are the game's (`call_reinforcements`). +pub fn after_hurt(m: &mut Mob, source: &Damage) { + if let Some(target) = m.ai.target.or(source.attacker) { + m.outbox + .push(MobEvent::Reinforcements { mob: m.id, target }); + } +} + pub fn register(m: &mut Mob) { use Species as S; match m.species() { S::Zombie | S::Husk | S::ZombieVillager => { let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(4, AttackTurtleEgg::default()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); @@ -42,6 +54,7 @@ pub fn register(m: &mut Mob) { S::ZombifiedPiglin => { // ZombifiedPiglin.addBehaviourGoals: neutral until provoked. let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); g.add(2, MeleeAttack::new(1.0, false).zombie()); @@ -58,6 +71,7 @@ pub fn register(m: &mut Mob) { S::Drowned => { // Zombie.registerGoals, then Drowned.addBehaviourGoals. let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(4, AttackTurtleEgg::default()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); @@ -136,12 +150,13 @@ pub fn register(m: &mut Mob) { } // The AbstractSkeleton constructor's reassessWeaponGoal, after // registerGoals; the weapon itself comes with finalizeSpawn. - let interval = if matches!(species, S::Bogged | S::Parched) { - 70 + // getHardAttackInterval / getAttackInterval. + let (hard, normal) = if matches!(species, S::Bogged | S::Parched) { + (50, 70) } else { - 40 + (20, 40) }; - m.ai.goals.add(4, SkeletonWeapon::new(interval)); + m.ai.goals.add(4, SkeletonWeapon::new(hard, normal)); } _ => {} } @@ -524,13 +539,20 @@ pub struct SkeletonWeapon { bow: BowAttack, melee: MeleeAttack, use_bow: bool, + /// The weapon the goal was last reassessed for. + assessed: Option, + hard_interval: i32, + normal_interval: i32, } impl SkeletonWeapon { - pub fn new(bow_interval: i32) -> Self { + pub fn new(hard_interval: i32, normal_interval: i32) -> Self { Self { - bow: BowAttack::new(1.0, bow_interval, 15.0), + bow: BowAttack::new(1.0, normal_interval, 15.0), melee: MeleeAttack::new(1.2, false), use_bow: false, + assessed: None, + hard_interval, + normal_interval, } } } @@ -556,6 +578,18 @@ impl Goal for SkeletonWeapon { fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { // The goal registered for the weapon in hand. self.use_bow = holding_bow(m); + // reassessWeaponGoal: a bow gets the hard interval only on Hard; it + // is picked again when the weapon changes (the first check after + // spawning or loading stands for finalizeSpawn and + // readAdditionalSaveData). + if self.assessed != Some(self.use_bow) { + self.assessed = Some(self.use_bow); + self.bow.interval_min = if cx.w.difficulty.hard { + self.hard_interval + } else { + self.normal_interval + }; + } if self.use_bow { self.bow.can_use(m, cx) } else { diff --git a/crates/horizon-client/src/mobs/vex.rs b/crates/horizon-client/src/mobs/vex.rs index de38f6da8..1301d1922 100644 --- a/crates/horizon-client/src/mobs/vex.rs +++ b/crates/horizon-client/src/mobs/vex.rs @@ -29,6 +29,7 @@ pub fn register(m: &mut Mob) { g.add(8, RandomMove); g.add(9, LookAtPlayer::new(3.0).probability(1.0)); g.add(10, LookAtPlayer::mobs(8.0)); + // `HurtByTargetGoal(this, Raider.class)`: raiders are ignored, vexes are not. let mut hurt = HurtByTarget::new().alert(); hurt.ignore = vec![ Species::Pillager, @@ -36,7 +37,7 @@ pub fn register(m: &mut Mob) { Species::Evoker, Species::Illusioner, Species::Witch, - Species::Vex, + Species::Ravager, ]; m.ai.targets.add(1, hurt); m.ai.targets.add(2, CopyOwnerTarget); diff --git a/crates/horizon-client/src/mobs/villager.rs b/crates/horizon-client/src/mobs/villager.rs index 575fafaed..aad0ec911 100644 --- a/crates/horizon-client/src/mobs/villager.rs +++ b/crates/horizon-client/src/mobs/villager.rs @@ -284,12 +284,14 @@ pub fn xp(m: &Mob) -> i32 { m.extra.trade.as_ref().map_or(0, |t| t.xp) } /// `Villager.setVillagerData(withProfession)`: a new profession forgets the -/// offers. +/// offers (`offers = null`), so the next `getOffers` rolls the new +/// profession's trades. pub fn set_profession(m: &mut Mob, profession: u8) { if m.extra.variant2 != profession { m.extra.variant2 = profession; if let Some(t) = m.extra.trade.as_mut() { t.offers.clear(); + t.unset = true; } } } @@ -446,6 +448,24 @@ impl Ext { } (stack.count > 0).then_some(stack) } + /// `SimpleContainer.removeItemType`: take up to `count` of the item from + /// the first stacks that hold it. + pub fn remove_item(&mut self, item: &str, mut count: u32) { + let mut slot = 0; + while slot < self.inventory.len() && count > 0 { + let s = &mut self.inventory[slot]; + if &*s.item == item { + let n = s.count.min(count); + s.count -= n; + count -= n; + if s.count == 0 { + self.inventory.remove(slot); + continue; + } + } + slot += 1; + } + } pub fn count_item(&self, item: &str) -> u32 { self.inventory .iter() @@ -752,30 +772,6 @@ pub fn finalize_summoned_golem(m: &mut Mob, random: &mut impl RandomSource) { } m.extra.left_handed = random.next_float() < 0.05; } -/// `PoiManager.take(HOME, ..., 48)`: the nearest bed head not `claimed` -/// (`free_head`: a bed head that is not occupied). -pub fn take_vacant_bed( - claimed: &[[i32; 3]], - from: [i32; 3], - mut free_head: impl FnMut(i32, i32, i32) -> bool, -) -> Option<[i32; 3]> { - let mut best: Option<(i32, [i32; 3])> = None; - for y in from[1] - 8..=from[1] + 8 { - for z in from[2] - 48..=from[2] + 48 { - for x in from[0] - 48..=from[0] + 48 { - let d = (x - from[0]).pow(2) + (y - from[1]).pow(2) + (z - from[2]).pow(2); - if d <= 48 * 48 - && best.is_none_or(|(b, _)| d < b) - && free_head(x, y, z) - && !claimed.contains(&[x, y, z]) - { - best = Some((d, [x, y, z])); - } - } - } - } - best.map(|(_, p)| p) -} /// `Villager.canBreed`: enough food, awake and an adult. pub fn can_breed(m: &Mob) -> bool { m.species() == Species::Villager @@ -846,6 +842,70 @@ pub fn read_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) { mod tests { use super::*; + fn villager() -> Mob { + use crate::mobs::{Anim, Brain, Flags, MobType, Part, Species}; + use std::{collections::HashMap, sync::Arc}; + let kind = Arc::new(MobType { + id: "minecraft:villager".into(), + species: Species::Villager, + width: 0.6, + height: 1.95, + eye_height: 1.62, + category: "misc".into(), + max_health: 20.0, + speed: 0.5, + flying_speed: 0.02, + attack_damage: 0.0, + attack_knockback: 0.0, + follow_range: 48.0, + step_height: 0.6, + armor: 0.0, + armor_toughness: 0.0, + knockback_resistance: 0.0, + jump_strength: 0.42, + attributes: Arc::default(), + gravity: 0.08, + safe_fall_distance: 3.0, + fall_damage_multiplier: 1.0, + water_efficiency: 0.0, + water_slowdown: 0.8, + air_speed: 0.02, + baby_scale: 0.5, + head_rot_speed: 10.0, + max_head_x_rot: 40.0, + max_head_y_rot: 75.0, + ambient_interval: 80, + flags: Flags::default(), + model: Arc::new(Part::parse("root", &serde_json::json!({}))), + layers: HashMap::new(), + texture: String::new(), + brain: Brain::Passive, + anim: Anim::Villager, + sounds: [""; 3], + step_sound: None, + food: Arc::default(), + tempt: Arc::default(), + }); + Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0) + } + #[test] + fn a_new_profession_rolls_new_offers() { + let mut m = villager(); + set_profession(&mut m, 1); + // Offers made for the first profession. + m.extra.trade.get_or_insert_default().unset = false; + // The same profession keeps them. + set_profession(&mut m, 1); + assert!(!m.extra.trade.as_ref().unwrap().unset); + // A new one forgets them (offers = null), so the next getOffers + // (ensure_offers) rolls the new profession's trades. + set_profession(&mut m, 2); + let t = m.extra.trade.as_ref().unwrap(); + assert!(t.offers.is_empty()); + assert!(t.unset); + assert_eq!(m.extra.variant2, 2); + } + fn stack(item: &str, count: u32) -> ItemStack { ItemStack::new(item, count) } diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 7b311a9e0..2052eef4c 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -1242,6 +1242,8 @@ enum B { WorkAtPoi { t: Timed, last_check: i64, + /// `WorkAtComposter` (a farmer's package) rather than `WorkAtPoi`. + composter: bool, }, StrollAroundPoi { running: bool, @@ -1635,7 +1637,11 @@ impl B { *birth = now + 275 + i64::from(m.random.next_int_bound(50).unwrap_or(0)); true } - B::WorkAtPoi { t, last_check } => { + B::WorkAtPoi { + t, + last_check, + composter, + } => { let mm = mem(m); let Some(site) = mm.job_site.value() else { return false; @@ -1656,6 +1662,13 @@ impl B { mm.last_worked_at_poi.set(now); mm.look_target.set(Tracker::Block(site)); play_work_sound(m, cx); + // WorkAtComposter.useWorkstation, before the restock. + if *composter { + cx.events.push(MobEvent::WorkAtComposter { + mob: m.id, + pos: site, + }); + } restock_if_due(m, cx.w); true } @@ -3396,8 +3409,10 @@ fn breeding_possible(m: &mut Mob, w: &MobWorld) -> bool { && super::villager::can_breed(m) && w.seen(id).is_some_and(|s| s.in_love) } -/// `VillagerMakeLove.takeVacantBed`: a bed head no villager has claimed. -fn take_bed_for_child(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { +/// `VillagerMakeLove.takeVacantBed`: `PoiManager.take(HOME, ..., 48)` over a +/// sphere of 48 blocks, the nearest bed head no villager has claimed that the +/// villager can walk to (`canReach`: a path that gets there). +fn take_bed_for_child(m: &mut Mob, w: &MobWorld) -> Option<[i32; 3]> { let claimed: Vec<[i32; 3]> = w .others .iter() @@ -3405,10 +3420,20 @@ fn take_bed_for_child(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { .filter_map(|s| s.bed) .chain(m.extra.bed) .collect(); - let p = m.position; - let from = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]; - super::villager::take_vacant_bed(&claimed, from, |x, y, z| { - w.prop(x, y, z, "part") == Some("head") && w.prop(x, y, z, "occupied") != Some("true") + let from = block_of(m.position); + let mut beds: Vec<[i32; 3]> = homes_in_range(m, w, 48) + .into_iter() + .filter(|p| { + w.prop(p[0], p[1], p[2], "part") == Some("head") + && w.prop(p[0], p[1], p[2], "occupied") != Some("true") + && !claimed.contains(p) + }) + .collect(); + // Nearest first; equally near beds stay in scan order. + beds.sort_by_key(|p| dist_sqr(*p, from)); + beds.into_iter().find(|&p| { + m.create_path_to(w, p, Poi::Home.valid_range()) + .is_some_and(|path| path.reached) }) } /// `BehaviorUtils.lockGazeAndWalkToEachOther`: this villager's half, and @@ -3775,6 +3800,7 @@ fn packages(baby: bool, seed: i64, farmer: bool) -> Vec { B::WorkAtPoi { t: timed(60, 60), last_check: 0, + composter: farmer, }, 7, ), diff --git a/crates/horizon-client/src/mobs/witch.rs b/crates/horizon-client/src/mobs/witch.rs index 0990ae01d..b0ec7866a 100644 --- a/crates/horizon-client/src/mobs/witch.rs +++ b/crates/horizon-client/src/mobs/witch.rs @@ -193,6 +193,11 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { } Who::Player => false, }; + // `target.hasEffect`: a target that already has the effect gets the next one. + let has = |effect: &str| match who { + Who::Player => cx.w.player.as_ref().is_some_and(|p| p.has_effect(effect)), + Who::Mob(id) => cx.w.seen(id).is_some_and(|s| s.has_effect(effect)), + }; let (effect, ticks, amplifier) = if heals { m.ai.target = None; if health <= 4.0 { @@ -200,11 +205,11 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { } else { ("minecraft:regeneration", 900, 0) } - } else if dist >= 8.0 { + } else if dist >= 8.0 && !has("slowness") { ("minecraft:slowness", 1800, 0) - } else if health >= 8.0 { + } else if health >= 8.0 && !has("poison") { ("minecraft:poison", 900, 0) - } else if dist <= 3.0 && m.random.next_float() < 0.25 { + } else if dist <= 3.0 && !has("weakness") && m.random.next_float() < 0.25 { ("minecraft:weakness", 1800, 0) } else { ("minecraft:instant_damage", 1, 0) @@ -236,6 +241,15 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { pitch, )); } +/// The movement speed of a witch with `SPEED_MODIFIER_DRINKING` (-0.25, +/// `ADD_VALUE`) while it drinks: it stands still for the 32 ticks. +pub fn speed(m: &Mob, base: f64) -> f64 { + if m.species() == Species::Witch && m.extra.flag { + (base - 0.25).max(0.0) + } else { + base + } +} /// Witch.aiStep before `super.aiStep()`: the healing cooldown, then drink /// water breathing, fire resistance, healing or swiftness. pub fn ai_step(m: &mut Mob, cx_events: &mut Vec, target_far: bool) { diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 7836a49ef..aa665decc 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -65,6 +65,35 @@ impl MobWorld<'_> { pub fn has_tag(&self, tag: &str, block: &str) -> bool { self.block_tags.get(tag).is_some_and(|s| s.contains(block)) } + /// `findBlocksInBoxByManhattanDistance(at, range_xz, range_y).filterState(..).findFirst()`: + /// the first block `matches` accepts (given its position and name), going + /// out from `at` by Manhattan distance as `BlockPos.withinBoxByManhattanDistance` does. + pub fn nearest_block( + &self, + at: [i32; 3], + range_xz: i32, + range_y: i32, + matches: impl Fn([i32; 3], &str) -> bool, + ) -> Option<[i32; 3]> { + // Visiting order: distance, then x, then y, then +z before -z. + let mut best: Option<((i32, i32, i32, bool), [i32; 3])> = None; + for dx in -range_xz..=range_xz { + for dy in -range_y..=range_y { + for dz in -range_xz..=range_xz { + let key = (dx.abs() + dy.abs() + dz.abs(), dx, dy, dz < 0); + if best.is_some_and(|(k, _)| k <= key) { + continue; + } + let p = [at[0] + dx, at[1] + dy, at[2] + dz]; + let state = self.world.block(p[0], p[1], p[2]); + if !self.facts.is_air(state) && matches(p, &self.facts.get(state).name) { + best = Some((key, p)); + } + } + } + } + best.map(|(_, p)| p) + } /// Level.clip with COLLIDER shapes (optionally stopping at any fluid): true on a miss. pub fn clear_between(&self, from: Vec3, to: Vec3, fluids_block: bool) -> bool { let mut scene = SceneCollisions { diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index a6f97981e..234390e23 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -44,12 +44,14 @@ use std::{ pub(crate) mod environment; mod fluids; -pub use fluids::{FluidResources, LAVA, PlayerFluids, WATER}; +pub use fluids::{FluidResources, FluidTags, LAVA, PlayerFluids, WATER}; pub struct MotionResources { pub attributes: Arc, pub blocks: BoundMovementProperties, pub climbable: BTreeSet, + /// `BlockTags.CAN_GLIDE_THROUGH`: climbable blocks a gliding player passes. + pub glide_through: BTreeSet, pub fall_reset: BTreeSet, pub landing: BlockLandingRegistry, pub health: crate::health::HealthResources, @@ -108,6 +110,7 @@ impl MotionResources { suppresses_bounce: tag("minecraft:suppresses_bounce")?, })?, climbable: tag("minecraft:climbable")?, + glide_through: tag("minecraft:can_glide_through")?, fall_reset: tag("minecraft:fall_damage_resetting")?, }) } @@ -308,6 +311,9 @@ pub struct PlayerMotion { /// it travels on the server: `isLocalInstanceAuthoritative` is false, so /// its moves check no fall damage. pub server_copy: bool, + /// The span of `events` a wall hit on the server's copy pushed (sound and + /// damage): `server_travel` keeps them while it drops the simulated moves. + pub wall_hit_events: Option>, /// A client's copy of its own player (`Level.isClientSide`): only the /// server hurts it (`Entity.hurt` does nothing on a client) and ticks its /// food (`ServerPlayer.tick`). Its hurt animation comes from the server. @@ -477,6 +483,7 @@ impl PlayerMotion { freeze_immune: false, old_position: position, server_copy: false, + wall_hit_events: None, client_side: false, block_effects: true, powder_snow_entered: Vec::new(), @@ -765,6 +772,10 @@ impl MotionWorld<'_, '_, '_> { .state(self.block(p)) .ok_or("missing scene state")? .name; + // A gliding player passes through vines (`CAN_GLIDE_THROUGH`). + if self.player.fall_flying && self.resources.glide_through.contains(name) { + return Ok(false); + } let climbable = self.resources.climbable.contains(name); if climbable { self.player.last_climbable = Some(p); @@ -834,9 +845,17 @@ impl MotionWorld<'_, '_, '_> { let can_glide = self.player.glider && !self.player.ground.on_ground && !self.player.flying + && self.player.status.levitation.is_none() && !self.player.fluids.was_touching_water && !self.player.is_in_lava(); - if jumping && !toggled && !was_jumping && !self.player.fall_flying && can_glide { + // LocalPlayer.aiStep: `!onClimbable()` before tryToStartFallFlying. + if jumping + && !toggled + && !was_jumping + && !self.climbable()? + && !self.player.fall_flying + && can_glide + { self.player.fall_flying = true; } // LivingEntity.tick fallFlyTicks, then updateFallFlying. @@ -848,8 +867,11 @@ impl MotionWorld<'_, '_, '_> { self.player.fall_distance = 1.0; } self.player.fall_fly_ticks += 1; - // canGlide alone: water and lava don't end a glide. - let still = self.player.glider && !self.player.ground.on_ground && !self.player.flying; + // canGlide alone: water and lava don't end a glide, levitation does. + let still = self.player.glider + && !self.player.ground.on_ground + && !self.player.flying + && self.player.status.levitation.is_none(); if !still { self.player.glide_ending = true; } else if (self.player.fall_fly_ticks + 1) % 10 == 0 @@ -1021,7 +1043,14 @@ impl MotionWorld<'_, '_, '_> { { horizon_world::living_fluid::travel_in_fluid(self, input)?; } else if self.player.fall_flying { - self.travel_fall_flying()?; + // LivingEntity.travelFallFlying: on a climbable the glide ends and + // the player moves like in the air. + if self.climbable()? { + self.travel_in_air(input)?; + self.player.fall_flying = false; + } else { + self.travel_fall_flying()?; + } } else { self.travel_in_air(input)?; } @@ -1048,7 +1077,14 @@ impl MotionWorld<'_, '_, '_> { p.server_copy = false; (p.position, p.ground, p.collision) = (position, ground, collision); (p.history, p.fall_distance, p.steps) = (history, fall_distance, steps); + // The wall hit is the server's own: its sound and damage events stay. + let kept: Vec<_> = p + .wall_hit_events + .take() + .map(|span| p.events.drain(span).collect()) + .unwrap_or_default(); p.events.truncate(events); + p.events.extend(kept); travelled } /// `LivingEntity.checkAutoSpinAttack`: the first living entity in the @@ -1083,7 +1119,7 @@ impl MotionWorld<'_, '_, '_> { /// `Entity.applyEffectsFromBlocks` for the blocks Horizon's player reacts /// to inside its movement path: powder snow (`PowderSnowBlock.entityInside` /// and the FREEZE / EXTINGUISH effects), cobwebs, sweet berry bushes, - /// cactus and lit campfires, and the magma block it stands on + /// cactus and lit campfires, and the magma and slime blocks it stands on /// (`stepOn`). Pressure plates, tripwires and rails are the world tick's /// scan. pub fn apply_effects_from_blocks(&mut self) -> Result<()> { @@ -1141,14 +1177,22 @@ impl MotionWorld<'_, '_, '_> { // MagmaBlock's hot floor hurts unless it steps carefully (sneaks). if self.player.ground.on_ground { let on = self.legacy_on_position()?; - let magma = self - .assets - .registry - .state(self.block(on)) - .is_some_and(|s| s.name == "minecraft:magma_block"); + let under = self.assets.registry.state(self.block(on)); + let magma = under.is_some_and(|s| s.name == "minecraft:magma_block"); + let slime = under.is_some_and(|s| s.name == "minecraft:slime_block"); if magma && !self.player.shift_key_down { self.environment_hurt("minecraft:hot_floor", 1.0)?; } + // SlimeBlock.stepOn: a slow walk on slime drags the horizontal + // speed (the client moves its own player, so not the server's copy). + if slime && !damage_only && !self.player.shift_key_down { + let abs_delta_y = self.player.velocity.y.abs(); + if abs_delta_y < 0.1 { + let scale = 0.4 + abs_delta_y * 0.2; + self.player.velocity.x *= scale; + self.player.velocity.z *= scale; + } + } } let mut visited = std::collections::HashSet::new(); let mut entered = Vec::new(); @@ -1528,7 +1572,7 @@ impl MotionWorld<'_, '_, '_> { let lean = pitch; let look_h = (look.x * look.x + look.z * look.z).sqrt(); let move_h = (v.x * v.x + v.z * v.z).sqrt(); - let gravity = 0.08; + let gravity = living_motion::effective_gravity(self)?; let lift = f64::from(lean.cos()).powi(2); let mut m = Vec3::new(v.x, v.y + gravity * (-1.0 + lift * 0.75), v.z); if m.y < 0.0 && look_h > 0.0 { @@ -1556,7 +1600,44 @@ impl MotionWorld<'_, '_, '_> { } self.player.velocity = Vec3::new(m.x * 0.99, m.y * 0.98, m.z * 0.99); let movement = self.player.velocity; - self.move_self(movement) + self.move_self(movement)?; + // LivingEntity.handleFallFlyingCollisions: only the server hurts. + if !self.player.client_side && self.player.collision.horizontal { + let new_h = { + let v = self.player.velocity; + (v.x * v.x + v.z * v.z).sqrt() + }; + let dmg = ((move_h - new_h) * 10.0 - 3.0) as f32; + if dmg > 0.0 { + let first = self.player.events.len(); + // getFallDamageSound((int) dmg) + let sound = if dmg as i32 > 4 { + crate::landing::SceneSound::BigFall + } else { + crate::landing::SceneSound::SmallFall + }; + self.player + .events + .push(MotionEvent::Fall(crate::landing::FallEvent::Sound { + sound, + position: None, + volume: 1.0, + pitch: 1.0, + })); + let id = self + .resources + .health + .damage + .id("minecraft:fly_into_wall") + .ok_or("missing fly_into_wall damage type")?; + let rules = self.scene.health_rules; + self.player.hurt(&self.resources.health, rules, id, dmg)?; + if self.player.server_copy { + self.player.wall_hit_events = Some(first..self.player.events.len()); + } + } + } + Ok(()) } /// `LocalPlayer.move`: the move, then `updateAutoJump` with the float /// horizontal displacement. diff --git a/crates/horizon-client/src/motion/environment.rs b/crates/horizon-client/src/motion/environment.rs index fc3c63c77..5e9bdfec8 100644 --- a/crates/horizon-client/src/motion/environment.rs +++ b/crates/horizon-client/src/motion/environment.rs @@ -161,6 +161,29 @@ pub(crate) fn fire_contact( } damage } +/// `CactusBlock.entityInside`: the box (deflated by `1.0E-5`, as in +/// `Entity.checkInsideBlocks`) touches a cactus; its inside shape is the +/// whole block. +pub(crate) fn cactus_contact( + world: &World, + registry: &horizon_world::registry::BlockRegistry, + bounds: Aabb, +) -> bool { + let b = bounds.deflate(Vec3::new(1e-5, 1e-5, 1e-5)); + for x in b.min_position().x.floor() as i32..=b.max_position().x.floor() as i32 { + for y in b.min_position().y.floor() as i32..=b.max_position().y.floor() as i32 { + for z in b.min_position().z.floor() as i32..=b.max_position().z.floor() as i32 { + if registry + .state(world.block(x, y, z)) + .is_some_and(|s| s.name == "minecraft:cactus") + { + return true; + } + } + } + } + false +} impl LivingFluidWorld for MotionWorld<'_, '_, '_> { fn in_water(&mut self) -> Result { Ok(self.player.fluids.was_touching_water) diff --git a/crates/horizon-client/src/motion/fluids.rs b/crates/horizon-client/src/motion/fluids.rs index 89affeb64..894e6d907 100644 --- a/crates/horizon-client/src/motion/fluids.rs +++ b/crates/horizon-client/src/motion/fluids.rs @@ -103,10 +103,10 @@ fn is_tagged(kind: FluidKind, tag: FluidTagId) -> bool { } impl PlayerMotion { pub fn fluid_height(&self, tag: FluidTagId) -> f64 { - self.fluids.interaction.fluid_height(&Membership, tag) + self.fluids.interaction.fluid_height(&FluidTags, tag) } pub fn is_eye_in_fluid(&self, tag: FluidTagId) -> bool { - self.fluids.interaction.is_eye_in_fluid(&Membership, tag) + self.fluids.interaction.is_eye_in_fluid(&FluidTags, tag) } /// `LocalPlayer.isUnderWater`: `wasUnderwater`, the eye sample `Player.tick` /// takes before the base tick (`Entity.isUnderWater` also needs the body @@ -116,11 +116,12 @@ impl PlayerMotion { } /// `Entity.isInLava`. pub fn is_in_lava(&self) -> bool { - !self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&Membership, LAVA) + !self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&FluidTags, LAVA) } } -struct Membership; -impl FluidMembership for Membership { +/// Which fluids `FluidTags.WATER` and `FluidTags.LAVA` hold. +pub struct FluidTags; +impl FluidMembership for FluidTags { fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool { is_tagged(kind, tag) } diff --git a/crates/horizon-client/src/moved_extras.rs b/crates/horizon-client/src/moved_extras.rs index 3b5db6fbc..4c99a49bf 100644 --- a/crates/horizon-client/src/moved_extras.rs +++ b/crates/horizon-client/src/moved_extras.rs @@ -25,7 +25,7 @@ pub struct Moved { pub name: &'static str, } -pub const MOVED: [Moved; 3] = [ +pub const MOVED: [Moved; 4] = [ Moved { id: "fps_overlay", name: "FPS counter", @@ -38,6 +38,10 @@ pub const MOVED: [Moved; 3] = [ id: "zoom", name: "Zoom", }, + Moved { + id: "minimap", + name: "Minimap", + }, ]; impl Moved { @@ -45,7 +49,8 @@ impl Moved { match self.id { "fps_overlay" => e.fps, "keystrokes" => e.keystrokes, - _ => e.zoom, + "zoom" => e.zoom, + _ => e.minimap, } } /// The extra's settings as the mod keeps them (its storage keys). @@ -60,11 +65,21 @@ impl Moved { ("corner", e.keystrokes_corner.name().into()), ("cps", flag(e.keystrokes_cps)), ], - _ => vec![ + "zoom" => vec![ ("level", e.zoom_level.to_string()), ("smooth", flag(e.zoom_smooth)), ("sensitivity", flag(e.zoom_sensitivity)), ], + _ => vec![ + ("corner", e.minimap_corner.name().into()), + ("size", e.minimap_size.to_string()), + ( + "shape", + if e.minimap_round { "round" } else { "square" }.into(), + ), + ("zoom", e.minimap_zoom.to_string()), + ("rotate", flag(e.minimap_rotate)), + ], } } /// The extra's key as the mod's key (`horizon-mods.txt`), when the @@ -149,21 +164,21 @@ pub fn fetch_index() -> Result { /// Installs `m` into the game folder `dir` (where `options.txt` is), for a /// player whose extras were `e`: its newest version for this game that -/// wasn't taken down, checked against the index (size, SHA-256, signature, -/// manifest), as `horizon-mods/.hmod`, turned on, with the extra's -/// settings in its storage unless the mod has its own already. +/// wasn't taken down and that this Horizon's mod API level runs, checked +/// against the index (size, SHA-256, signature, manifest), as +/// `horizon-mods/.hmod`, turned on, with the extra's settings in its +/// storage unless the mod has its own already. pub fn install(index: &Index, m: &Moved, e: &Extras, dir: &Path) -> Result<(), String> { let project = index .project(m.id) .ok_or_else(|| format!("{} isn't in Horizon's registry yet", m.id))?; - let version = project - .versions - .iter() - .find(|v| { - v.game == horizon_core::version::VERSION_ID - && index.revoked(&project.id, &v.file.sha256).is_none() - }) - .ok_or_else(|| format!("{} has no version for this game", m.id))?; + let version = index + .installable( + project, + horizon_core::version::VERSION_ID, + horizon_mods::api_level::LEVEL, + ) + .ok_or_else(|| format!("{} has no version this Horizon can run", m.id))?; let mods = dir.join(crate::mod_list::DIR); fs::create_dir_all(&mods).map_err(|e| format!("{}: {e}", mods.display()))?; let target = mods.join(format!("{}.hmod", m.id)); @@ -208,7 +223,7 @@ mod tests { fn only_extras_that_were_on_are_installed_and_each_once() { let mut e = Extras::default(); assert!(pending(&e).is_empty()); - assert_eq!(nothing_to_do(&e).len(), 3); + assert_eq!(nothing_to_do(&e).len(), 4); e.fps = true; e.fps_corner = Corner::BottomRight; e.fps_lows = true; @@ -223,7 +238,7 @@ mod tests { ); e.moved.push("fps_overlay".into()); assert!(pending(&e).is_empty()); - assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2]]); + assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2], &MOVED[3]]); // Zoom: its settings, and its key only when the player changed it. e.zoom = true; e.zoom_level = 8; @@ -240,6 +255,23 @@ mod tests { assert!(MOVED[2].keys(&e).is_empty()); e.zoom_key = "key.mouse.4".into(); assert_eq!(MOVED[2].keys(&e), [("zoom", "key.mouse.4".into())]); + // The minimap: its five settings, in the extra's values. + e.minimap = true; + e.minimap_corner = Corner::BottomLeft; + e.minimap_size = 128; + e.minimap_round = true; + e.minimap_zoom = 0.5; + assert_eq!(pending(&e), [&MOVED[2], &MOVED[3]]); + assert_eq!( + MOVED[3].settings(&e), + [ + ("corner", "bottom_left".into()), + ("size", "128".into()), + ("shape", "round".into()), + ("zoom", "0.5".into()), + ("rotate", "false".into()) + ] + ); assert_eq!( MOVED[0].notice(), "FPS counter moved to the mod browser: installed for you" diff --git a/crates/horizon-client/src/multiplayer.rs b/crates/horizon-client/src/multiplayer.rs index 07d8552ff..39ea26502 100644 --- a/crates/horizon-client/src/multiplayer.rs +++ b/crates/horizon-client/src/multiplayer.rs @@ -11,9 +11,15 @@ use std::{ collections::HashMap, path::{Path, PathBuf}, sync::{Arc, Mutex}, - time::Duration, + time::{Duration, Instant}, }; +const SERVER_NAME: crate::clipboard::FieldId = ("server.name", 0); +const SERVER_ADDRESS: crate::clipboard::FieldId = ("server.address", 0); +const SERVER_DIRECT: crate::clipboard::FieldId = ("server.direct", 0); +const FRIEND_CODE: crate::clipboard::FieldId = ("friend.code", 0); +const FRIEND_RELAY: crate::clipboard::FieldId = ("friend.relay", 0); + /// The result of pinging one address. #[derive(Clone, Debug)] pub enum Ping { @@ -491,7 +497,7 @@ impl Screen { w / 2.0 - 100.0, h / 4.0 + 96.0 + 18.0, 200.0, - "Done", + &crate::lang::get_or("gui.done", "Done"), servers::parse_address(address).is_some(), 50, ), @@ -499,7 +505,7 @@ impl Screen { w / 2.0 - 100.0, h / 4.0 + 120.0 + 18.0, 200.0, - "Cancel", + &crate::lang::get_or("gui.cancel", "Cancel"), true, 51, ), @@ -532,7 +538,7 @@ impl Screen { w / 2.0 - 100.0, h / 4.0 + 96.0 + 12.0, 200.0, - "Join Server", + &crate::lang::get_or("selectServer.select", "Join Server"), servers::parse_address(address).is_some(), 52, ), @@ -540,7 +546,7 @@ impl Screen { w / 2.0 - 100.0, h / 4.0 + 120.0 + 12.0, 200.0, - "Cancel", + &crate::lang::get_or("gui.cancel", "Cancel"), true, 53, ), @@ -558,7 +564,7 @@ impl Screen { w / 2.0 - 100.0, h / 4.0 + 120.0 + 18.0, 200.0, - "Cancel", + &crate::lang::get_or("gui.cancel", "Cancel"), true, 57, ), @@ -568,7 +574,7 @@ impl Screen { w / 2.0 - 100.0, h / 2.0 + 30.0, 200.0, - "Back to Server List", + &crate::lang::get_or("gui.toMenu", "Back to Server List"), true, 54, )]; @@ -587,18 +593,21 @@ impl Screen { } } /// An EditBox and its label: the label at `label_y` (one pixel in, in - /// 0xA0A0A0), the 200x20 box at `box_y`. + /// 0xA0A0A0), the 200x20 box at `box_y`. The box is the same `EditBox` + /// the world screens edit (`Caret`): cursor, selection and scrolling. fn field( atlas: &HudAtlas, batch: &mut HudBatch, + caret: &mut crate::ui::Caret, + focused_at: Instant, label: &str, value: &str, + id: crate::clipboard::FieldId, + max: usize, (label_y, box_y): (f32, f32), focused: bool, - blink: bool, ) { let x = (batch.width.ceil() / 2.0).floor() - 100.0; - crate::widgets::target_as([x, box_y, 200.0, 20.0], crate::widgets::Kind::Field); batch.text( atlas, label, @@ -607,35 +616,25 @@ impl Screen { rgba(160, 160, 160, 255), true, ); - batch.fill( + caret.draw( + batch, atlas, - x, - box_y, - 200.0, - 20.0, - if focused { - u32::MAX - } else { - rgba(160, 160, 160, 255) - }, - ); - batch.fill(atlas, x + 1.0, box_y + 1.0, 198.0, 18.0, rgba(0, 0, 0, 255)); - let shown = if focused && blink { - format!("{value}_") - } else { - value.to_owned() - }; - batch.text( - atlas, - &shown, - x + 4.0, - box_y + 6.0, - rgba(224, 224, 224, 255), - true, + (id, value, max), + [x, box_y, 200.0, 20.0], + focused, + focused_at, ); } - pub fn draw(&self, atlas: &HudAtlas, batch: &mut HudBatch, blink: bool, cursor: (f32, f32)) { + pub fn draw( + &self, + atlas: &HudAtlas, + batch: &mut HudBatch, + caret: &mut crate::ui::Caret, + focused_at: Instant, + cursor: (f32, f32), + ) { let (w, h) = (batch.width, batch.height); + let max = self.max_length(); crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false); let centered = |batch: &mut HudBatch, text: &str, y: f32, color: u32| { let tw = HudBatch::text_width(atlas, text); @@ -876,10 +875,10 @@ impl Screen { } => { centered( batch, - if index.is_some() { - "Edit Server Info" + &if index.is_some() { + crate::lang::get_or("manageServer.edit.title", "Edit Server Info") } else { - "Add Server" + crate::lang::get_or("manageServer.add.title", "Add Server") }, 17.0, u32::MAX, @@ -888,20 +887,26 @@ impl Screen { Self::field( atlas, batch, - "Server Name", + caret, + focused_at, + &crate::lang::get_or("manageServer.enterName", "Server Name"), name, + SERVER_NAME, + max, (53.0, 66.0), *focus == 0, - blink, ); Self::field( atlas, batch, - "Server Address", + caret, + focused_at, + &crate::lang::get_or("manageServer.enterIp", "Server Address"), address, + SERVER_ADDRESS, + max, (94.0, 106.0), *focus == 1, - blink, ); } Self::Direct { address } => { @@ -916,11 +921,14 @@ impl Screen { Self::field( atlas, batch, - &crate::lang::get_or("addServer.enterIp", "Server Address"), + caret, + focused_at, + &crate::lang::get_or("manageServer.enterIp", "Server Address"), address, + SERVER_DIRECT, + max, (100.0, 116.0), true, - blink, ); } Self::ConfirmDelete { name, lines, .. } => { @@ -954,26 +962,32 @@ impl Screen { Self::field( atlas, batch, + caret, + focused_at, "Invite Code", code, + FRIEND_CODE, + max, (53.0, 64.0), *focus == 0, - blink, ); Self::field( atlas, batch, + caret, + focused_at, "Relay (optional)", relay, + FRIEND_RELAY, + max, (94.0, 105.0), *focus == 1, - blink, ); } Self::Disconnected { reason, retry } => { centered( batch, - "Connection Lost", + &crate::lang::get_or("disconnect.lost", "Connection Lost"), h / 2.0 - 40.0, rgba(170, 170, 170, 255), ); @@ -1019,7 +1033,7 @@ impl Screen { (Self::List { .. }, Some(42)) => { *self = Self::Edit { index: None, - name: "Minecraft Server".into(), + name: crate::lang::get_or("selectServer.defaultName", "Minecraft Server"), address: String::new(), focus: 1, packs: None, @@ -1261,6 +1275,19 @@ impl Screen { scroll.release(); } } + /// The focused box, in GUI pixels: name at 66, address at 106 + /// (`ManageServerScreen`), direct address at 116, friend fields at 64 and 105. + pub fn focused_box(&self, (w, _h): (f32, f32)) -> Option<[f32; 4]> { + let y = match self { + Self::Edit { focus: 0, .. } => 66.0, + Self::Edit { .. } => 106.0, + Self::Direct { .. } => 116.0, + Self::Friend { focus: 0, .. } => 64.0, + Self::Friend { .. } => 105.0, + _ => return None, + }; + Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0]) + } fn focused_field(&mut self) -> Option<&mut String> { match self { Self::Edit { @@ -1297,11 +1324,11 @@ impl Screen { /// The focused text field, its id and maximum length (copy and paste). pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> { let id = match self { - Self::Edit { focus: 0, .. } => ("server.name", 0), - Self::Edit { .. } => ("server.address", 0), - Self::Direct { .. } => ("server.direct", 0), - Self::Friend { focus: 0, .. } => ("friend.code", 0), - Self::Friend { .. } => ("friend.relay", 0), + Self::Edit { focus: 0, .. } => SERVER_NAME, + Self::Edit { .. } => SERVER_ADDRESS, + Self::Direct { .. } => SERVER_DIRECT, + Self::Friend { focus: 0, .. } => FRIEND_CODE, + Self::Friend { .. } => FRIEND_RELAY, _ => return None, }; let max = self.max_length(); @@ -1315,21 +1342,6 @@ impl Screen { 128 } } - pub fn type_text(&mut self, text: &str) { - let max = self.max_length(); - if let Some(field) = self.focused_field() { - for c in text.chars().filter(|c| !c.is_control()) { - if field.chars().count() < max { - field.push(c); - } - } - } - } - pub fn backspace(&mut self) { - if let Some(field) = self.focused_field() { - field.pop(); - } - } pub fn tab(&mut self) { if let Self::Edit { focus, .. } | Self::Friend { focus, .. } = self { *focus = (*focus + 1) % 2; diff --git a/crates/horizon-client/src/net.rs b/crates/horizon-client/src/net.rs index 0eb260477..34daaf85c 100644 --- a/crates/horizon-client/src/net.rs +++ b/crates/horizon-client/src/net.rs @@ -40,6 +40,8 @@ pub enum Event { Status(String), /// The server's `minecraft:brand` custom payload. Brand(String), + /// The server runs mods' quiet commands (`horizon_protocol::quiet`). + QuietCommands, /// Configuration finished: the play state begins with these registries. Joined { registries: Arc, @@ -668,6 +670,8 @@ fn receive_brand(payload: &cfg::CustomPayload, send: &Sender) { && let Ok(brand) = horizon_protocol::buf::Reader::new(&payload.data).string(32767) { let _ = send.send(Event::Brand(brand)); + } else if payload.channel == horizon_protocol::quiet::CHANNEL { + let _ = send.send(Event::QuietCommands); } } diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index b0df37367..61fda9878 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -3377,7 +3377,7 @@ mod tests { s.extras ); // In a window too short for the page, the wheel scrolls to its end. - let short = (480.0, 300.0); + let short = (480.0, 180.0); ui.scroll(&s, short, -1000.0); let (_, top, bottom, content) = ui.layout(&s, short.0, short.1); assert!(content > bottom - top); diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index 219d59b6b..8a709067d 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -186,12 +186,13 @@ impl Projectile { self } /// The shooter's bow enchantments; Flame spawns the arrow burning for 100 - /// seconds (`projectile_spawned` ignite). + /// seconds (`projectile_spawned` ignite), Piercing sets its pierce level. pub fn set_bonus(&mut self, bonus: crate::mobs::gear::ArrowBonus) { - if bonus.flame - && let Some(a) = self.arrow.as_mut() - { - a.ignite_for_seconds(100.0); + if let Some(a) = self.arrow.as_mut() { + if bonus.flame { + a.ignite_for_seconds(100.0); + } + a.pierce = bonus.pierce.min(127) as u8; } self.bonus = bonus; } diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 0c353bb38..5431adcd6 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -36,7 +36,7 @@ use horizon_world::{ worldgen::terrain::TerrainBlockStates, }; use std::{ - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, fs, path::{Path, PathBuf}, sync::{ @@ -247,6 +247,44 @@ pub struct SaveTables { block_entity: Vec, heightmaps: crate::assets::Properties, air: u32, + /// The POI type and its max tickets of each state (`PoiTypes`). + poi: Vec>, +} +/// `PoiTypes.register`: the POI type of a block state and its max tickets. +pub fn poi_type(state: &horizon_world::chunk::BlockState) -> Option<(&'static str, i32)> { + let name = state.name.strip_prefix("minecraft:")?; + Some(match name { + "blast_furnace" => ("minecraft:armorer", 1), + "smoker" => ("minecraft:butcher", 1), + "cartography_table" => ("minecraft:cartographer", 1), + "brewing_stand" => ("minecraft:cleric", 1), + "composter" => ("minecraft:farmer", 1), + "barrel" => ("minecraft:fisherman", 1), + "fletching_table" => ("minecraft:fletcher", 1), + "cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => { + ("minecraft:leatherworker", 1) + } + "lectern" => ("minecraft:librarian", 1), + "stonecutter" => ("minecraft:mason", 1), + "loom" => ("minecraft:shepherd", 1), + "smithing_table" => ("minecraft:toolsmith", 1), + "grindstone" => ("minecraft:weaponsmith", 1), + "bell" => ("minecraft:meeting", 32), + "beehive" => ("minecraft:beehive", 0), + "bee_nest" => ("minecraft:bee_nest", 0), + "nether_portal" => ("minecraft:nether_portal", 0), + "lodestone" => ("minecraft:lodestone", 0), + "test_instance_block" => ("minecraft:test_instance", 0), + // PoiTypes.BEDS is the sixteen colours: no straw bed. + bed if bed.ends_with("_bed") && bed != "straw_bed" => { + if state.properties.get("part").map(String::as_str) != Some("head") { + return None; + } + ("minecraft:home", 1) + } + rod if rod.ends_with("lightning_rod") => ("minecraft:lightning_rod", 0), + _ => return None, + }) } impl SaveTables { pub fn new(assets: &crate::assets::Assets) -> Self { @@ -275,6 +313,9 @@ impl SaveTables { block_entity: Vec::new(), heightmaps: assets.properties.clone(), air: registry.default_state("minecraft:air").map_or(0, |s| s.0), + poi: (0..registry.len()) + .map(|id| registry.state(BlockStateId(id as u32)).and_then(poi_type)) + .collect(), } } /// Tables with only the heightmaps' state facts, for tests. @@ -287,6 +328,7 @@ impl SaveTables { block_entity: Vec::new(), heightmaps, air: 0, + poi: Vec::new(), } } /// Which states carry a block entity (`BlockFacts`), to drop entries for @@ -297,6 +339,35 @@ impl SaveTables { } } +/// A container's loot table and what Horizon rolled from it. Vanilla keeps +/// `LootTable` and `LootTableSeed` until the container is first opened; +/// Horizon rolls when it generates or loads the chunk, and saves the table +/// again while the container still holds exactly the rolled items. +#[derive(Clone, Debug, PartialEq)] +pub struct RolledLoot { + pub pos: (i32, i32, i32), + pub table: String, + /// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable). + pub seed: Option, + /// The rolled `Items`. + pub items: Tag, +} +impl RolledLoot { + /// Puts the loot table back in place of `Items` while they are still the + /// roll; false once the container holds anything else. + pub fn restore(&self, map: &mut Compound) -> bool { + if map.get(&"Items".into()) != Some(&self.items) { + return false; + } + map.remove(&"Items".into()); + map.insert("LootTable".into(), string(&self.table)); + if let Some(seed) = self.seed.filter(|s| *s != 0) { + map.insert("LootTableSeed".into(), Tag::Long(seed)); + } + true + } +} + /// Save data loaded with a chunk that Horizon keeps but does not simulate. #[derive(Clone, Debug, Default)] pub struct ChunkExtras { @@ -318,6 +389,8 @@ pub struct ChunkExtras { pub fluid_ticks: Vec, /// Game time written as `LastUpdate`. pub last_update: i64, + /// Containers still holding their loot table's roll. + pub rolled_loot: Vec, } fn tick_list(ticks: &[SavedTick], name: impl Fn(u32) -> Option) -> Tag { @@ -526,6 +599,12 @@ pub fn encode_block_entity( } } BlockEntity::Sculk(s) => entries.extend(s.to_nbt()), + // CampfireBlockEntity.saveAdditional. + BlockEntity::Campfire(c) => { + entries.push(("Items", encode_items(&c.items))); + entries.push(("CookingTimes", Tag::IntArray(c.progress.to_vec()))); + entries.push(("CookingTotalTimes", Tag::IntArray(c.total.to_vec()))); + } BlockEntity::Crafter(c) => { entries.push(("Items", encode_items(&c.items))); entries.push(( @@ -569,6 +648,7 @@ pub fn encode_block_entity( BlockEntity::Vault(v) => entries.extend(v.save()), BlockEntity::Beehive(h) => entries.extend(h.to_nbt()), BlockEntity::CommandBlock(c) => entries.extend(c.save()), + BlockEntity::DaylightDetector => {} } Tag::compound(entries) } @@ -594,6 +674,34 @@ pub fn decode_banner(tag: &Tag) -> crate::containers::Banner { }, } } +/// A loaded container with a loot table and no items yet, rolled as +/// generation rolls it (Horizon does not unpack on first open). +fn roll_loaded_loot( + chunk: &Chunk, + biomes: &Biomes, + tag: &Tag, + be: &mut ((i32, i32, i32), crate::containers::BlockEntity), +) -> Option { + let crate::containers::BlockEntity::Chest(slots) = &mut be.1 else { + return None; + }; + let table = text(tag.get("LootTable"))?; + if slots.iter().any(Option::is_some) { + return None; + } + let loot = crate::structure_blocks::loaded_loot_data()?; + let (x, y, z) = be.0; + let biome = &biomes.get(chunk.biome(x & 15, y, z & 15)).name; + let seed = crate::structure_blocks::loot_seed(tag); + Some(crate::structure_blocks::roll_container( + slots, + Some(&loot), + table, + seed, + be.0, + biome, + )) +} pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::containers::BlockEntity)> { use crate::containers::BlockEntity; let int = |k: &str| number(tag.get(k)).map(|v| v as i32); @@ -676,8 +784,10 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain Tag::Int(n) => *n, _ => continue, }; - f.recipes_used - .insert(id.to_string_lossy(), count.max(0) as u32); + // Saves from before the key was namespaced hold both forms of an id. + *f.recipes_used + .entry(crate::containers::recipe_used_key(&id.to_string_lossy())) + .or_insert(0) += count.max(0) as u32; } } } @@ -704,6 +814,18 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain } BlockEntity::Sign(sign) => sign.decode(tag), BlockEntity::Sculk(s) => s.load(tag), + // CampfireBlockEntity.loadAdditional: missing time arrays stay zeros. + BlockEntity::Campfire(c) => { + for (slot, item) in decode_items(tag.get("Items"), 4).into_iter().enumerate() { + c.items[slot] = item; + } + let ints = |key: &str| match tag.get(key) { + Some(Tag::IntArray(v)) => std::array::from_fn(|i| v.get(i).copied().unwrap_or(0)), + _ => [0; 4], + }; + c.progress = ints("CookingTimes"); + c.total = ints("CookingTotalTimes"); + } BlockEntity::Crafter(c) => { for (slot, item) in decode_items(tag.get("Items"), 9).into_iter().enumerate() { c.items[slot] = item; @@ -724,7 +846,8 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain | BlockEntity::Lectern(_) | BlockEntity::Banner(_) | BlockEntity::CreakingHeart(_) - | BlockEntity::Skull(_) => {} + | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector => {} BlockEntity::Spawner(s) => **s = crate::mobs::spawner::Spawner::load(tag), BlockEntity::TrialSpawner(s) => **s = crate::mobs::trial_spawner::TrialSpawner::load(tag), BlockEntity::Vault(v) => **v = crate::vault::Vault::load(tag), @@ -734,18 +857,164 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain Some((pos, entity)) } +/// The chunk's points of interest as `PoiManager` saves them: each section +/// with POI blocks, valid, with a record per block at its max tickets (a +/// freshly made world). None when the chunk has none. +pub fn encode_poi(chunk: &Chunk, tables: &SaveTables) -> Option { + let kind = |v: u32| tables.poi.get(v as usize).copied().flatten(); + let mut sections = Compound::new(); + let mut states = [0u32; 4096]; + for (si, section) in chunk.sections.iter().enumerate() { + if !section.blocks.maybe_has(|v| kind(v).is_some()) { + continue; + } + section.unpack(&mut states); + let y = (MIN_Y >> 4) + si as i32; + let records: Vec = states + .iter() + .enumerate() + .filter_map(|(i, &v)| { + let (id, tickets) = kind(v)?; + let i = i as i32; + let pos = [ + chunk.x * 16 + (i & 15), + y * 16 + (i >> 8), + chunk.z * 16 + ((i >> 4) & 15), + ]; + let mut record = vec![("pos", Tag::IntArray(pos.to_vec())), ("type", string(id))]; + // PoiRecord.CODEC: free_tickets defaults to 0 and is then left out. + if tickets != 0 { + record.push(("free_tickets", Tag::Int(tickets))); + } + Some(Tag::compound(record)) + }) + .collect(); + if !records.is_empty() { + sections.insert( + y.to_string().as_str().into(), + Tag::compound([("Valid", Tag::Byte(1)), ("Records", Tag::List(records))]), + ); + } + } + (!sections.is_empty()).then(|| poi_root(sections)) +} +/// A record's `free_tickets` from the POI file the chunk had, while the same +/// POI is still there: a bed or job site a villager claimed stays claimed. +fn keep_claims(saved: &mut NamedTag, old: &NamedTag) { + let key = |r: &Tag| match (r.get("pos"), text(r.get("type"))) { + (Some(Tag::IntArray(pos)), Some(kind)) => Some((pos.clone(), kind)), + _ => None, + }; + let mut claims = HashMap::new(); + if let Some(Tag::Compound(sections)) = old.tag.get("Sections") { + for section in sections.values() { + if let Some(Tag::List(records)) = section.get("Records") { + for r in records { + if let Some(k) = key(r) { + claims.insert(k, number(r.get("free_tickets")).unwrap_or(0) as i32); + } + } + } + } + } + let Tag::Compound(root) = &mut saved.tag else { + return; + }; + let Some(Tag::Compound(sections)) = root.get_mut(&"Sections".into()) else { + return; + }; + for section in sections.values_mut() { + let Tag::Compound(section) = section else { + continue; + }; + let Some(Tag::List(records)) = section.get_mut(&"Records".into()) else { + continue; + }; + for r in records { + let Some(&tickets) = key(r).and_then(|k| claims.get(&k)) else { + continue; + }; + if let Tag::Compound(r) = r { + if tickets == 0 { + r.remove(&"free_tickets".into()); + } else { + r.insert("free_tickets".into(), Tag::Int(tickets)); + } + } + } + } +} +fn poi_root(sections: Compound) -> NamedTag { + NamedTag { + name: "".into(), + tag: Tag::compound([ + ("DataVersion", Tag::Int(DATA_VERSION)), + ("Sections", Tag::Compound(sections)), + ]), + } +} + +/// What a dimension's saved chunks depend on: skylight and the build height +/// (`DimensionType.minY` and `height`). `Chunk` always holds the Overworld's +/// sections; a level saves only the ones inside its own height. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LevelShape { + pub sky_light: bool, + pub min_y: i32, + pub height: i32, +} +impl LevelShape { + pub const OVERWORLD: Self = Self { + sky_light: true, + min_y: MIN_Y, + height: SECTIONS as i32 * 16, + }; + pub const NETHER: Self = Self { + sky_light: false, + min_y: 0, + height: 256, + }; + pub const END: Self = Self { + sky_light: true, + min_y: 0, + height: 256, + }; + /// The shape of a dimension folder (`dimensions/minecraft/`, or the + /// old `DIM-1` and `DIM1`). + pub fn for_dir(root: &Path) -> Self { + if root.ends_with("the_nether") || root.ends_with("DIM-1") { + Self::NETHER + } else if root.ends_with("the_end") || root.ends_with("DIM1") { + Self::END + } else { + Self::OVERWORLD + } + } + fn min_section(self) -> i32 { + self.min_y >> 4 + } + fn section_count(self) -> usize { + (self.height >> 4) as usize + } + fn has_section(self, y: i32) -> bool { + (self.min_section()..self.min_section() + self.section_count() as i32).contains(&y) + } +} + /// A column's sections as `Heightmap.primeHeightmaps` reads them. struct HeightmapView<'a> { sections: &'a [Arc
], tables: &'a SaveTables, + min_y: i32, + height: i32, } impl HeightmapChunk for HeightmapView<'_> { type State = crate::assets::StateFacts; fn min_y(&self) -> i32 { - MIN_Y + self.min_y } fn height(&self) -> i32 { - SECTIONS as i32 * 16 + self.height } fn highest_section_y(&self) -> i32 { let top = self @@ -765,7 +1034,7 @@ impl HeightmapChunk for HeightmapView<'_> { } } /// Heightmaps kept after world generation (`ChunkStatus.FULL.heightmapsAfter`). -fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag { +fn heightmaps(chunk: &Chunk, tables: &SaveTables, shape: LevelShape) -> Tag { let kinds = [ HeightmapType::WorldSurface, HeightmapType::OceanFloor, @@ -774,11 +1043,13 @@ fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag { ]; let mut maps: Vec = kinds .iter() - .filter_map(|k| Heightmap::new(*k, MIN_Y, SECTIONS as i32 * 16).ok()) + .filter_map(|k| Heightmap::new(*k, shape.min_y, shape.height).ok()) .collect(); let view = HeightmapView { sections: &chunk.sections, tables, + min_y: shape.min_y, + height: shape.height, }; let mut refs: Vec<&mut Heightmap> = maps.iter_mut().collect(); if Heightmap::prime(&view, &mut refs).is_err() { @@ -808,7 +1079,12 @@ pub fn client_heightmaps_in( .iter() .filter_map(|(id, k)| Heightmap::new(*k, min_y, height).ok().map(|m| (*id, m))) .collect(); - let view = HeightmapView { sections, tables }; + let view = HeightmapView { + sections, + tables, + min_y, + height, + }; let mut refs: Vec<&mut Heightmap> = maps.iter_mut().map(|(_, m)| m).collect(); if Heightmap::prime(&view, &mut refs).is_err() { return Vec::new(); @@ -834,17 +1110,18 @@ fn nibbles(light: &Light, sky: bool) -> Vec { } pub fn encode(chunk: &Chunk, registry: &BlockRegistry, biomes: &Biomes) -> NamedTag { - encode_with(chunk, registry, biomes, None, true) + encode_with(chunk, registry, biomes, None, LevelShape::OVERWORLD) } /// The chunk NBT vanilla's `SerializableChunkData.write` produces for a full -/// chunk. `sky_light` is false in dimensions without skylight. +/// chunk in a level of `shape`. pub fn encode_with( chunk: &Chunk, registry: &BlockRegistry, biomes: &Biomes, tables: Option<&SaveTables>, - sky_light: bool, + shape: LevelShape, ) -> NamedTag { + let sky_light = shape.sky_light; let mut sections = Vec::new(); let air = tables.map_or(0, |t| t.air); // Sky layers below the highest section with light other than 15 are @@ -856,6 +1133,9 @@ pub fn encode_with( }); for (si, section) in chunk.sections.iter().enumerate() { let y = (MIN_Y >> 4) + si as i32; + if !shape.has_section(y) { + continue; + } // PalettedContainer.pack: distinct states in index order, indices at // the saved width. let packed = section.blocks.pack(); @@ -959,7 +1239,16 @@ pub fn encode_with( } None => current, }; - crate::block_entity_nbt::canonical(&merged).unwrap_or(merged) + let mut saved = crate::block_entity_nbt::canonical(&merged).unwrap_or(merged); + if let Some(r) = extras.and_then(|x| x.rolled_loot.iter().find(|r| r.pos == *p)) + && let Tag::Compound(map) = &mut saved + && !r.restore(map) + { + // A loaded source still carries the table; kept, it rolls again on load. + map.remove(&"LootTable".into()); + map.remove(&"LootTableSeed".into()); + } + saved }) .collect(); if let Some(extras) = extras { @@ -985,7 +1274,7 @@ pub fn encode_with( ("DataVersion", Tag::Int(DATA_VERSION)), ("xPos", Tag::Int(chunk.x)), ("zPos", Tag::Int(chunk.z)), - ("yPos", Tag::Int(MIN_Y >> 4)), + ("yPos", Tag::Int(shape.min_section())), ("Status", string("minecraft:full")), ("LastUpdate", Tag::Long(extras.map_or(0, |e| e.last_update))), ("isLightOn", Tag::Byte(i8::from(chunk.lit))), @@ -1017,11 +1306,30 @@ pub fn encode_with( .collect(); s.insert("References".into(), Tag::Compound(refs)); } + let starts: Compound = chunk + .structure_starts + .iter() + .filter_map(|start| match start.save() { + Some(tag) => Some((start.name.as_str().into(), tag)), + None => { + log::warn!( + "Horizon: structure start {} not saved: one of its pieces cannot be", + start.name + ); + None + } + }) + .collect(); + if !starts.is_empty() + && let Some(Tag::Compound(s)) = map.get_mut(&"structures".into()) + { + s.insert("starts".into(), Tag::Compound(starts)); + } map.entry("PostProcessing".into()) - .or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); SECTIONS])); + .or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); shape.section_count()])); } if let Some(t) = tables { - put(&mut root, "Heightmaps", heightmaps(chunk, t)); + put(&mut root, "Heightmaps", heightmaps(chunk, t, shape)); let names = |k: u32| t.block_names.get(k as usize).cloned(); let (blocks, fluids) = extras.map_or((&[][..], &[][..]), |e| { (&e.block_ticks[..], &e.fluid_ticks[..]) @@ -1189,10 +1497,13 @@ fn decode_column( extras.block_entity_order.push((x, y, z)); } match decode_block_entity(e) { - Some(be) => { + Some(mut be) => { if let Some(name) = text(e.get("CustomName")) { chunk.block_names.push((be.0, name)); } + if let Some(rolled) = roll_loaded_loot(&chunk, biomes, e, &mut be) { + extras.rolled_loot.push(rolled); + } extras.block_entity_sources.push((be.0, e.clone())); chunk.block_entities.push(be); } @@ -1237,18 +1548,19 @@ pub fn encode_proto( registry: &BlockRegistry, biomes: &Biomes, tables: Option<&SaveTables>, - sky_light: bool, + shape: LevelShape, ) -> NamedTag { - let mut root = encode_with(&proto.column, registry, biomes, tables, sky_light); + let mut root = encode_with(&proto.column, registry, biomes, tables, shape); let status = if proto.decorated { ChunkStatus::Features } else { ChunkStatus::Terrain }; // ProtoChunk.postProcessing: one list per section, packed section offsets. - let mut marks = vec![Vec::new(); SECTIONS]; + let mut marks = vec![Vec::new(); shape.section_count()]; for &[x, y, z] in &proto.column.postprocess { - if let Some(list) = marks.get_mut(((y - MIN_Y) >> 4) as usize) { + let section = usize::try_from((y - shape.min_y) >> 4).ok(); + if let Some(list) = section.and_then(|s| marks.get_mut(s)) { list.push(Tag::Short( ((x & 15) | (y & 15) << 4 | (z & 15) << 8) as i16, )); @@ -1555,6 +1867,9 @@ enum Record { Tag(Arc), /// A chunk to encode on the writer thread. Chunk(Arc, Arc), + /// A chunk's points of interest, made on the writer thread against the + /// saved ones (`resolve_poi`). + Poi(Arc, Arc), Delete, } /// What encoding a chunk needs, captured when it is queued. @@ -1562,7 +1877,7 @@ struct Encoding { registry: &'static BlockRegistry, biomes: Arc, tables: Option>, - sky_light: bool, + shape: LevelShape, } impl Record { fn tag(&self) -> Option> { @@ -1573,16 +1888,88 @@ impl Record { e.registry, &e.biomes, e.tables.as_deref(), - e.sky_light, + e.shape, ))), + Record::Poi(c, t) => encode_poi(c, t).map(Arc::new), Record::Delete => None, } } } +/// A queued POI record as it is written: claims kept from the saved one, and +/// a chunk that lost its last POI gets an empty one, as vanilla trusts a +/// saved section. None when the chunk never had any. +fn resolve_poi( + root: &Path, + cache: &RegionCache, + (x, z): (i32, i32), + record: &Record, +) -> Option { + let Record::Poi(chunk, tables) = record else { + return Some(record.clone()); + }; + let old = read_region_record(root, cache, "poi", x, z).unwrap_or_else(|e| { + log::warn!("Horizon: could not read the poi record of chunk {x} {z}: {e}"); + None + }); + let tag = match (encode_poi(chunk, tables), old) { + (Some(mut tag), Some(old)) => { + keep_claims(&mut tag, &old); + tag + } + (Some(tag), None) => tag, + (None, Some(_)) => poi_root(Compound::new()), + (None, None) => return None, + }; + Some(Record::Tag(Arc::new(tag))) +} +fn read_region_record( + root: &Path, + cache: &RegionCache, + folder: &str, + x: i32, + z: i32, +) -> Result> { + let key = (x >> 5, z >> 5); + let path = region_path(root, folder, key.0, key.1); + let Some(region) = cached_region(cache, key, &path, false)? else { + return Ok(None); + }; + let record = region + .lock() + .map_err(|_| "region file poisoned")? + .read(ChunkPos::new(x, z))?; + Ok(match record { + Some(r) => Some(r.nbt(Limits::default())?), + None => None, + }) +} /// Chunk and entity records waiting for the writer thread, by (entities?, /// x, z): reads see them before the region files do. -type PendingWrites = Mutex>; -type WriteBatch = (bool, Vec<((i32, i32), Record, u64)>); +type PendingWrites = Mutex>; +type WriteBatch = (Folder, Vec<((i32, i32), Record, u64)>); +/// The region folders of a dimension. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Folder { + Region, + Entities, + Poi, +} +impl Folder { + fn of(name: &str) -> Self { + match name { + "entities" => Self::Entities, + "poi" => Self::Poi, + _ => Self::Region, + } + } + fn name(self) -> &'static str { + match self { + Self::Region => "region", + Self::Entities => "entities", + Self::Poi => "poi", + } + } +} /// The background thread that encodes chunks and writes them into the region files, in /// submission order. @@ -1604,30 +1991,79 @@ pub struct SaveDir { pub root: PathBuf, regions: Arc, entity_regions: Arc, + poi_regions: Arc, pending: Arc, sequence: AtomicU64, writer: Mutex>, outstanding: Arc, /// Set by `Persistent` before the first load. pub tables: OnceLock>, - /// False in dimensions without skylight (no SkyLight layers are written). - pub sky_light: bool, + /// The dimension's skylight and height, from its folder name. + pub shape: LevelShape, +} +fn is_forced_ticket(ticket: &Tag) -> bool { + matches!(ticket.get("type"), Some(Tag::String(t)) if *t == JavaString::from("minecraft:forced")) } impl SaveDir { pub fn new(root: PathBuf) -> Self { - let sky_light = !root.ends_with("the_nether") && !root.ends_with("DIM-1"); + let shape = LevelShape::for_dir(&root); Self { root, regions: Default::default(), entity_regions: Default::default(), + poi_regions: Default::default(), pending: Default::default(), sequence: AtomicU64::new(0), writer: Mutex::new(None), outstanding: Default::default(), tables: OnceLock::new(), - sky_light, + shape, } } + /// 26.3's TicketStorage: persistent tickets live in each dimension's + /// data/minecraft/chunk_tickets.dat, with ChunkPos.CODEC's [x, z] int array. + pub fn load_forced_chunks(&self) -> HashSet<(i32, i32)> { + let Some(data) = read_saved_data(&self.root, "chunk_tickets") else { + return HashSet::new(); + }; + let Some(Tag::List(tickets)) = data.get("tickets") else { + return HashSet::new(); + }; + tickets + .iter() + .filter(|t| is_forced_ticket(t)) + .filter_map(|t| match t.get("chunk_pos")? { + Tag::IntArray(pos) if pos.len() == 2 => Some((pos[0], pos[1])), + _ => None, + }) + .collect() + } + pub fn save_forced_chunks(&self, forced: &HashSet<(i32, i32)>) -> Result<()> { + if forced.is_empty() && !saved_data_path(&self.root, "chunk_tickets").exists() { + return Ok(()); + } + let mut data = compound_or_empty(read_saved_data(&self.root, "chunk_tickets")); + // Preserve persistent tickets Horizon does not simulate. + let mut tickets = match data.get("tickets") { + Some(Tag::List(tickets)) => tickets + .iter() + .filter(|t| !is_forced_ticket(t)) + .cloned() + .collect::>(), + _ => Vec::new(), + }; + let mut chunks: Vec<_> = forced.iter().copied().collect(); + chunks.sort_unstable(); + tickets.extend(chunks.into_iter().map(|(x, z)| { + Tag::compound([ + ("type", Tag::String("minecraft:forced".into())), + ("level", Tag::Int(31)), // ChunkMap.FORCED_TICKET_LEVEL: ENTITY_TICKING. + ("chunk_pos", Tag::IntArray(vec![x, z])), + ]) + })); + put(&mut data, "tickets", Tag::List(tickets)); + write_saved_data(&self.root, "chunk_tickets", data) + } fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf { region_path(&self.root, folder, rx, rz) } @@ -1636,27 +2072,23 @@ impl SaveDir { .pending .lock() .ok()? - .get(&(folder != "region", x, z)) + .get(&(Folder::of(folder), x, z)) .map(|(_, r)| r.clone()); if let Some(record) = pending { return record.tag().map(|t| (*t).clone()); } - let key = (x >> 5, z >> 5); - let cache = if folder == "region" { - &self.regions - } else { - &self.entity_regions + let cache = match Folder::of(folder) { + Folder::Region => &self.regions, + Folder::Entities => &self.entity_regions, + Folder::Poi => &self.poi_regions, }; - let path = self.region_path(folder, key.0, key.1); - let region = cached_region(cache, key, &path, false).ok()??; - let record = region.lock().ok()?.read(ChunkPos::new(x, z)).ok()??; - record.nbt(Limits::default()).ok() + read_region_record(&self.root, cache, folder, x, z).ok()? } pub fn load(&self, x: i32, z: i32, registry: &BlockRegistry, biomes: &Biomes) -> Option { let root = self.read_record("region", x, z)?; let tables = self.tables.get().map(|t| &**t); let mut chunk = decode_with(&root, registry, biomes, tables)?; - load_light(&root.tag, &mut chunk, self.sky_light); + load_light(&root.tag, &mut chunk, self.shape.sky_light); Some(chunk) } /// A proto chunk saved before it became final (`decode_proto`). @@ -1681,7 +2113,7 @@ impl SaveDir { let records = protos .iter() .map(|p| { - let tag = encode_proto(p, registry, biomes, tables, self.sky_light); + let tag = encode_proto(p, registry, biomes, tables, self.shape); ((p.column.x, p.column.z), Some(tag)) }) .collect(); @@ -1710,16 +2142,16 @@ impl SaveDir { .into_iter() .map(|(k, t)| (k, t.map_or(Record::Delete, |t| Record::Tag(Arc::new(t))))) .collect(); - self.queue(folder != "region", records, wait) + self.queue(Folder::of(folder), records, wait) } - fn queue(&self, entities: bool, records: Vec<((i32, i32), Record)>, wait: bool) -> Result<()> { + fn queue(&self, folder: Folder, records: Vec<((i32, i32), Record)>, wait: bool) -> Result<()> { if !records.is_empty() { let mut batch = Vec::with_capacity(records.len()); { let mut pending = self.pending.lock().map_err(|_| "pending writes poisoned")?; for (key, record) in records { let seq = self.sequence.fetch_add(1, Ordering::Relaxed); - pending.insert((entities, key.0, key.1), (seq, record.clone())); + pending.insert((folder, key.0, key.1), (seq, record.clone())); batch.push((key, record, seq)); } } @@ -1730,7 +2162,7 @@ impl SaveDir { .map_err(|_| "writer poisoned")? += 1; let mut writer = self.writer.lock().map_err(|_| "writer poisoned")?; let writer = writer.get_or_insert_with(|| self.start_writer()); - if writer.send.send((entities, batch)).is_err() { + if writer.send.send((folder, batch)).is_err() { *self .outstanding .error @@ -1753,30 +2185,36 @@ impl SaveDir { fn start_writer(&self) -> Writer { let (send, receive) = mpsc::channel::(); let root = self.root.clone(); - let (regions, entity_regions) = (self.regions.clone(), self.entity_regions.clone()); + let (regions, entity_regions, poi_regions) = ( + self.regions.clone(), + self.entity_regions.clone(), + self.poi_regions.clone(), + ); let pending = self.pending.clone(); let outstanding = self.outstanding.clone(); let thread = std::thread::Builder::new() .name("horizon-region-writer".into()) .spawn(move || { - for (entities, batch) in receive { - let (folder, cache) = if entities { - ("entities", &entity_regions) - } else { - ("region", ®ions) + for (kind, batch) in receive { + let cache = match kind { + Folder::Region => ®ions, + Folder::Entities => &entity_regions, + Folder::Poi => &poi_regions, }; + let folder = kind.name(); // IOWorker.pendingWrites: a chunk queued again since is // written once, by its newest batch. let newest = |k: &(i32, i32), seq: u64| { pending.lock().map_or(true, |p| { - p.get(&(entities, k.0, k.1)).is_none_or(|p| p.0 <= seq) + p.get(&(kind, k.0, k.1)).is_none_or(|p| p.0 <= seq) }) }; - let records: Vec<_> = batch + let resolved: Vec<_> = batch .iter() .filter(|(k, _, seq)| newest(k, *seq)) - .map(|(k, r, _)| (*k, r)) + .filter_map(|(k, r, _)| Some((*k, resolve_poi(&root, cache, *k, r)?))) .collect(); + let records: Vec<_> = resolved.iter().map(|(k, r)| (*k, r)).collect(); let result = write_region_records(&root, cache, folder, &records); if let Err(e) = &result { let message = format!("failed to write {folder} files: {e}"); @@ -1788,11 +2226,8 @@ impl SaveDir { && let Ok(mut pending) = pending.lock() { for (k, _, seq) in &batch { - if pending - .get(&(entities, k.0, k.1)) - .is_some_and(|p| p.0 == *seq) - { - pending.remove(&(entities, k.0, k.1)); + if pending.get(&(kind, k.0, k.1)).is_some_and(|p| p.0 == *seq) { + pending.remove(&(kind, k.0, k.1)); } } } @@ -1856,13 +2291,24 @@ impl SaveDir { .map(|c| { ( (c.x, c.z), - Some(encode_with(c, registry, biomes, tables, self.sky_light)), + Some(encode_with(c, registry, biomes, tables, self.shape)), ) }) .collect(); self.write_records("region", records, true)?; + self.save_poi(chunks, true)?; Ok(chunks.len()) } + fn save_poi(&self, chunks: &[Arc], wait: bool) -> Result<()> { + let Some(tables) = self.tables.get() else { + return Ok(()); + }; + let records = chunks + .iter() + .map(|c| ((c.x, c.z), Record::Poi(c.clone(), tables.clone()))) + .collect(); + self.queue(Folder::Poi, records, wait) + } /// `save` on the writer thread: returns before the files are written; /// loads see the chunks meanwhile. pub fn save_later( @@ -1875,13 +2321,14 @@ impl SaveDir { registry, biomes: biomes.clone(), tables: self.tables.get().cloned(), - sky_light: self.sky_light, + shape: self.shape, }); let records = chunks .iter() .map(|c| ((c.x, c.z), Record::Chunk(c.clone(), encoding.clone()))) .collect(); - self.queue(false, records, false)?; + self.queue(Folder::Region, records, false)?; + self.save_poi(chunks, false)?; Ok(chunks.len()) } /// Write the entity chunks (`EntityStorage`): an empty list deletes it. @@ -3326,7 +3773,7 @@ impl SaveDir { .pending .lock() .ok()? - .get(&(false, x, z)) + .get(&(Folder::Region, x, z)) .map(|(_, record)| record.clone()); let root = if let Some(record) = pending { record.tag().map(|tag| (*tag).clone())? @@ -3345,7 +3792,7 @@ impl SaveDir { { return None; } - decode_lod_surfaces(&root, assets, biomes, self.sky_light) + decode_lod_surfaces(&root, assets, biomes, self.shape.sky_light) } } @@ -3436,6 +3883,277 @@ impl ChunkGenerator for Persistent { #[cfg(test)] mod tests { + #[test] + fn points_of_interest_save_as_poi_sections() { + let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else { + return; + }; + let assets = crate::assets::Assets::load(&PathBuf::from(root)).expect("assets"); + let tables = SaveTables::new(&assets); + let state = |name: &str, props: &[(&str, &str)]| { + let mut s = assets + .registry + .state(assets.registry.default_state(name).unwrap()) + .unwrap() + .clone(); + for (k, v) in props { + s.properties.insert((*k).into(), (*v).into()); + } + assets.registry.id(&s).unwrap().0 + }; + let mut chunk = Chunk::empty(2, -1); + chunk.set(1, 70, 1, state("minecraft:red_bed", &[("part", "head")])); + chunk.set(1, 70, 2, state("minecraft:red_bed", &[("part", "foot")])); + chunk.set(5, 71, 5, state("minecraft:bell", &[])); + chunk.set(6, 71, 5, state("minecraft:stone", &[])); + chunk.set(9, 70, 9, state("minecraft:straw_bed", &[("part", "head")])); + assert!(encode_poi(&Chunk::empty(0, 0), &tables).is_none()); + let saved = encode_poi(&chunk, &tables).expect("records"); + let Some(Tag::Compound(sections)) = saved.tag.get("Sections") else { + panic!("no sections"); + }; + assert_eq!(sections.len(), 1); + let section = §ions[&"4".into()]; + assert_eq!(section.get("Valid"), Some(&Tag::Byte(1))); + let Some(Tag::List(records)) = section.get("Records") else { + panic!("no records"); + }; + let records: Vec<_> = records + .iter() + .map(|r| { + ( + text(r.get("type")).unwrap(), + r.get("pos").cloned(), + number(r.get("free_tickets")), + ) + }) + .collect(); + assert_eq!( + records, + [ + ( + "minecraft:home".into(), + Some(Tag::IntArray(vec![33, 70, -15])), + Some(1) + ), + ( + "minecraft:meeting".into(), + Some(Tag::IntArray(vec![37, 71, -11])), + Some(32) + ), + ] + ); + } + + #[test] + fn a_chunk_that_loses_its_last_poi_saves_empty_sections() { + let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else { + return; + }; + let root = PathBuf::from(root); + let assets = crate::assets::Assets::load(&root).expect("assets"); + let biomes = Arc::new(Biomes::load(&root).expect("biomes")); + let bell = assets.registry.default_state("minecraft:bell").unwrap().0; + let dir = std::env::temp_dir().join(format!("horizon-poi-save-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let save = SaveDir::new(dir.clone()); + let _ = save.tables.set(Arc::new(SaveTables::new(&assets))); + let mut chunk = Chunk::empty(0, 0); + chunk.set(5, 71, 5, bell); + let sections = |save: &SaveDir| match save.read_record("poi", 0, 0)?.tag.get("Sections") { + Some(Tag::Compound(s)) => Some(s.len()), + _ => None, + }; + save.save_later(&[Arc::new(chunk.clone())], assets.registry, &biomes) + .unwrap(); + save.flush().unwrap(); + assert_eq!(sections(&save), Some(1)); + chunk.set(5, 71, 5, 0); + save.save_later(&[Arc::new(chunk)], assets.registry, &biomes) + .unwrap(); + save.flush().unwrap(); + assert_eq!(sections(&SaveDir::new(dir.clone())), Some(0)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn saved_poi_records_keep_their_claims() { + let record = |pos: [i32; 3], kind: &str, tickets: Option| { + let mut r = vec![("pos", Tag::IntArray(pos.to_vec())), ("type", string(kind))]; + if let Some(t) = tickets { + r.push(("free_tickets", Tag::Int(t))); + } + Tag::compound(r) + }; + let poi = |records: Vec| { + let mut sections = Compound::new(); + sections.insert( + "4".into(), + Tag::compound([("Valid", Tag::Byte(1)), ("Records", Tag::List(records))]), + ); + poi_root(sections) + }; + let old = poi(vec![ + record([1, 70, 1], "minecraft:home", None), + record([5, 71, 5], "minecraft:meeting", Some(30)), + ]); + let mut saved = poi(vec![ + record([1, 70, 1], "minecraft:home", Some(1)), + record([5, 71, 5], "minecraft:meeting", Some(32)), + record([9, 71, 9], "minecraft:home", Some(1)), + ]); + keep_claims(&mut saved, &old); + let expected = poi(vec![ + record([1, 70, 1], "minecraft:home", None), + record([5, 71, 5], "minecraft:meeting", Some(30)), + record([9, 71, 9], "minecraft:home", Some(1)), + ]); + assert_eq!(saved.tag, expected.tag); + } + + #[test] + fn unopened_rolled_containers_save_their_loot_table() { + let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else { + return; + }; + let root = PathBuf::from(root); + let assets = crate::assets::Assets::load(&root).expect("assets"); + let biomes = Biomes::load(&root).expect("biomes"); + let tables = SaveTables::new(&assets); + let chest = assets.registry.default_state("minecraft:chest").unwrap(); + let mut slots = vec![None; 27]; + slots[3] = Some(crate::items::ItemStack::new("minecraft:bone", 2)); + let mut chunk = Chunk::empty(0, 0); + chunk.set(1, 64, 2, chest.0); + chunk.block_entities.push(( + (1, 64, 2), + crate::containers::BlockEntity::Chest(slots.clone()), + )); + let rolled = RolledLoot { + pos: (1, 64, 2), + table: "minecraft:chests/simple_dungeon".into(), + seed: Some(42), + items: encode_items(&slots), + }; + chunk.saved = Some(Arc::new(ChunkExtras { + rolled_loot: vec![rolled], + ..ChunkExtras::default() + })); + let saved_chest = |chunk: &Chunk| -> Tag { + let saved = encode_with( + chunk, + assets.registry, + &biomes, + Some(&tables), + LevelShape::OVERWORLD, + ); + let Some(Tag::List(list)) = saved.tag.get("block_entities") else { + panic!("no block entities"); + }; + list[0].clone() + }; + let unopened = saved_chest(&chunk); + assert_eq!( + text(unopened.get("LootTable")).as_deref(), + Some("minecraft:chests/simple_dungeon") + ); + assert_eq!(number(unopened.get("LootTableSeed")), Some(42)); + assert!(unopened.get("Items").is_none()); + // Taking an item makes the items the container's own. + if let crate::containers::BlockEntity::Chest(s) = &mut chunk.block_entities[0].1 { + s[3] = None; + s[4] = Some(crate::items::ItemStack::new("minecraft:bone", 1)); + } + let opened = saved_chest(&chunk); + assert!(opened.get("LootTable").is_none()); + assert!(opened.get("Items").is_some()); + // Loaded with its table, rolled, then emptied: no table, or it refills on load. + let mut extras = ChunkExtras::clone(chunk.saved.as_ref().unwrap()); + extras.block_entity_sources = vec![( + (1, 64, 2), + Tag::compound([ + ("id", string("minecraft:chest")), + ("x", Tag::Int(1)), + ("y", Tag::Int(64)), + ("z", Tag::Int(2)), + ("LootTable", string("minecraft:chests/simple_dungeon")), + ("LootTableSeed", Tag::Long(42)), + ]), + )]; + chunk.saved = Some(Arc::new(extras)); + if let crate::containers::BlockEntity::Chest(s) = &mut chunk.block_entities[0].1 { + s.fill(None); + } + let emptied = saved_chest(&chunk); + assert!(emptied.get("LootTable").is_none()); + assert!(emptied.get("LootTableSeed").is_none()); + } + + #[test] + fn nether_and_end_chunks_save_with_their_own_height() { + let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else { + return; + }; + let root = PathBuf::from(root); + let assets = crate::assets::Assets::load(&root).expect("assets"); + let biomes = Biomes::load(&root).expect("biomes"); + let tables = SaveTables::new(&assets); + let rack = assets + .registry + .default_state("minecraft:netherrack") + .unwrap(); + let mut chunk = Chunk::empty(0, 0); + for y in 0..128 { + chunk.set(0, y, 0, rack.0); + } + let saved = encode_with( + &chunk, + assets.registry, + &biomes, + Some(&tables), + LevelShape::NETHER, + ); + assert_eq!(number(saved.tag.get("yPos")), Some(0)); + let Some(Tag::List(sections)) = saved.tag.get("sections") else { + panic!("no sections"); + }; + let ys: Vec = sections + .iter() + .filter_map(|s| number(s.get("Y")).map(|y| y as i32)) + .collect(); + assert_eq!(ys, (0..16).collect::>()); + let Some(Tag::List(post)) = saved.tag.get("PostProcessing") else { + panic!("no PostProcessing"); + }; + assert_eq!(post.len(), 16); + // Heightmap values count from the level's minimum: the column's top + // block at y=127 reads 128 (vanilla), not 192 (from the Overworld's -64). + let Some(Tag::LongArray(surface)) = saved + .tag + .get("Heightmaps") + .and_then(|h| h.get("WORLD_SURFACE")) + else { + panic!("no WORLD_SURFACE"); + }; + assert_eq!(surface[0] & 0x1ff, 128); + let overworld = encode_with( + &chunk, + assets.registry, + &biomes, + Some(&tables), + LevelShape::OVERWORLD, + ); + assert_eq!(number(overworld.tag.get("yPos")), Some(-4)); + assert_eq!( + LevelShape::for_dir(Path::new("w/dimensions/minecraft/the_end")), + LevelShape::END + ); + assert_eq!( + LevelShape::for_dir(Path::new("w/DIM-1")), + LevelShape::NETHER + ); + } + #[test] fn saved_far_surface_reads_builds_and_water_without_gameplay_chunks() { let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else { @@ -3554,7 +4272,7 @@ mod tests { "unfinished proto saves must not suppress complete decoration" ); let mut nether = SaveDir::new(dir.clone()); - nether.sky_light = false; + nether.shape = LevelShape::NETHER; let dark = nether.lod_surfaces(-2, 3, &assets, &biomes).unwrap(); assert!( dark.iter() @@ -3873,6 +4591,31 @@ mod tests { assert_eq!(back.redstone_signal(), 2); } #[test] + fn campfire_block_entity_round_trips() { + let mut c = crate::containers::Campfire::default(); + c.items[1] = Some(crate::items::ItemStack::new("minecraft:beef", 1)); + c.progress[1] = 120; + c.total[1] = 600; + let tag = encode_block_entity( + (3, 64, -2), + &crate::containers::BlockEntity::Campfire(Box::new(c.clone())), + "minecraft:soul_campfire", + ); + // One block entity type for both campfires. + assert_eq!(text(tag.get("id")).as_deref(), Some("minecraft:campfire")); + let (pos, back) = decode_block_entity(&tag).expect("campfire"); + assert_eq!(pos, (3, 64, -2)); + let crate::containers::BlockEntity::Campfire(back) = back else { + panic!("not a campfire"); + }; + assert_eq!(*back, c); + // The update tag a server's clients get: the items alone. + let update = + crate::lan::chunks::entity_tag(&crate::containers::BlockEntity::Campfire(Box::new(c))) + .expect("update tag"); + assert!(update.get("Items").is_some() && update.get("CookingTimes").is_none()); + } + #[test] fn spawner_block_entity_round_trips() { // A dungeon spawner as vanilla saves it (`id` is mob_spawner, not the block). let saved = horizon_world::nbt::parse_snbt( @@ -4382,4 +5125,84 @@ mod tests { }; assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3)); } + + /// A save written before the key was namespaced holds an id twice, once bare: vanilla + /// refuses such a furnace, so a load merges them under the full id. + #[test] + fn a_furnaces_recipes_used_merges_bare_and_full_ids() { + use crate::containers::BlockEntity; + let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else { + panic!("a furnace"); + }; + f.recipes_used + .insert("iron_ingot_from_smelting_iron_ore".to_owned(), 2); + f.recipes_used + .insert("minecraft:iron_ingot_from_smelting_iron_ore".to_owned(), 3); + let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace"); + let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else { + panic!("a furnace back"); + }; + assert_eq!(back.recipes_used.len(), 1); + assert_eq!( + back.recipes_used + .get("minecraft:iron_ingot_from_smelting_iron_ore"), + Some(&5) + ); + } + + #[test] + fn forced_chunks_survive_dimension_restarts_and_removals() { + let root = std::env::temp_dir().join(format!("horizon-forced-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let dimensions = [ + "minecraft:overworld", + "minecraft:the_nether", + "minecraft:the_end", + ]; + for (i, dim) in dimensions.iter().enumerate() { + let save = SaveDir::new(dimension_dir(&root, dim)); + assert!(save.load_forced_chunks().is_empty()); + let forced = HashSet::from([(i as i32, -17), (-23, i as i32), (-1, -1)]); + save.save_forced_chunks(&forced).unwrap(); + } + for (i, dim) in dimensions.iter().enumerate() { + let path = dimension_dir(&root, dim); + let save = SaveDir::new(path.clone()); + let mut forced = save.load_forced_chunks(); + assert_eq!( + forced, + HashSet::from([(i as i32, -17), (-23, i as i32), (-1, -1)]) + ); + let mut data = read_saved_data(&path, "chunk_tickets").unwrap(); + let other = Tag::compound([ + ("type", Tag::String("minecraft:portal".into())), + ("level", Tag::Int(30)), + ("chunk_pos", Tag::IntArray(vec![7, -8])), + ("ticks_left", Tag::Long(42)), + ]); + if let Some(Tag::List(tickets)) = data.get_mut("tickets") { + assert!( + tickets + .iter() + .all(|t| t.get("level") == Some(&Tag::Int(31))) + ); + tickets.push(other.clone()); + } + write_saved_data(&path, "chunk_tickets", data).unwrap(); + forced.remove(&(-1, -1)); + save.save_forced_chunks(&forced).unwrap(); + drop(save); + let reopened = SaveDir::new(path.clone()); + assert_eq!(reopened.load_forced_chunks(), forced); + reopened.save_forced_chunks(&HashSet::new()).unwrap(); + assert!(SaveDir::new(path.clone()).load_forced_chunks().is_empty()); + assert_eq!( + read_saved_data(&path, "chunk_tickets") + .unwrap() + .get("tickets"), + Some(&Tag::List(vec![other])) + ); + } + fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/horizon-client/src/sprint.rs b/crates/horizon-client/src/sprint.rs index c2099306c..872667b55 100644 --- a/crates/horizon-client/src/sprint.rs +++ b/crates/horizon-client/src/sprint.rs @@ -134,3 +134,76 @@ impl SprintState { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn standing() -> Conditions { + Conditions { + enough_food: true, + on_ground: true, + ..Conditions::default() + } + } + + fn step(state: &mut SprintState, keys: Keys, sprinting: &mut bool) { + let current = *sprinting; + let mut next = current; + state + .tick( + keys, + Conditions { + sprinting: current, + ..standing() + }, + 7, + |value| { + next = value; + Ok::<(), ()>(()) + }, + ) + .unwrap(); + *sprinting = next; + } + + /// Vanilla sprint is a state. Releasing the sprint key, including on the + /// jump tick, keeps it while forward is held; releasing forward stops it. + #[test] + fn sprint_survives_releasing_the_key_through_a_jump() { + let mut state = SprintState::default(); + let mut sprinting = false; + step( + &mut state, + Keys { + forward: true, + sprint: true, + ..Keys::default() + }, + &mut sprinting, + ); + assert!(sprinting); + step( + &mut state, + Keys { + forward: true, + jump: true, + ..Keys::default() + }, + &mut sprinting, + ); + assert!( + sprinting, + "a jump after the sprint key comes up must stay a sprint" + ); + step( + &mut state, + Keys { + jump: true, + ..Keys::default() + }, + &mut sprinting, + ); + assert!(!sprinting); + } +} diff --git a/crates/horizon-client/src/stations.rs b/crates/horizon-client/src/stations.rs index 715c2663e..a307b35cf 100644 --- a/crates/horizon-client/src/stations.rs +++ b/crates/horizon-client/src/stations.rs @@ -14,7 +14,8 @@ use std::{fs, path::Path, sync::Arc}; /// Everything the workstation menus read, loaded once with the resources. pub struct StationData { - pub enchantments: Enchantments, + /// The same registry `Items` carries (`Items::enchantments`). + pub enchantments: std::sync::Arc, pub recipes: StationRecipes, pub brewing: crate::brewing::Brewing, pub trades: crate::merchant::Trades, @@ -25,7 +26,7 @@ pub struct StationData { impl StationData { pub fn load(root: &Path, items: &Items) -> Self { Self { - enchantments: Enchantments::load(root), + enchantments: std::sync::Arc::clone(&items.enchantments), recipes: StationRecipes::load(root, items), brewing: crate::brewing::Brewing::load(root, items), trades: crate::merchant::Trades::load(root), @@ -174,7 +175,9 @@ pub fn increased_repair_cost(cost: u32) -> u32 { pub fn hover_name(names: &dyn Fn(&str) -> String, stack: &ItemStack) -> String { stack .custom_name() - .map_or_else(|| names(&stack.item), str::to_owned) + .map(str::to_owned) + .or_else(|| stack.item_name()) + .unwrap_or_else(|| names(&stack.item)) } /// `AnvilMenu.createResult`. `name` is the rename box text (None = untouched). pub fn anvil_result( diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs index 103f7402b..0c9ae0c5b 100644 --- a/crates/horizon-client/src/streaming.rs +++ b/crates/horizon-client/src/streaming.rs @@ -175,6 +175,11 @@ pub enum Done { } struct Queued { priority: i64, + /// The column the job is for and what it adds to the column's distance + /// (`Streaming::submit`): the priority is computed again from them when + /// the centre moves (`Streaming::requeue`). None for saves, which go first. + at: Option<(i32, i32)>, + bias: i64, order: u64, job: Job, } @@ -222,6 +227,9 @@ pub struct Streaming { /// radius it was last ranked for. decoration_order: DecorationOrder, order_area: Option<((i32, i32), i32)>, + /// What the queued jobs' priorities were computed from (`requeue`): the + /// centre, the loading screen, the band, and the extra areas. + queue_basis: (((i32, i32), bool, Option), Vec<((i32, i32), i32)>), finishing: FxHashSet<(i32, i32)>, decorates: bool, lighting: FxHashMap<(i32, i32), u64>, @@ -544,6 +552,7 @@ impl Streaming { saving: 0, decoration_order: DecorationOrder::default(), order_area: None, + queue_basis: Default::default(), finishing: Default::default(), decorates: generator.decorates(), lighting: Default::default(), @@ -639,20 +648,48 @@ impl Streaming { self.lit.remove(&(x, z)); self.meshed.remove(&(x, z)); self.lighting.remove(&(x, z)); - self.meshing.retain(|k, _| (k.0, k.2) != (x, z)); - self.waiting.retain(|k| (k.0, k.2) != (x, z)); + // By key, not by a pass over every pending section: a moved or + // shrunk storage forgets thousands of columns in one frame. + for sy in 0..SECTIONS as i32 { + self.meshing.remove(&(x, sy, z)); + self.waiting.remove(&(x, sy, z)); + } world.dirty_sections.take_column(x, z); world.chunks.remove(&(x, z)) } - fn submit(&mut self, priority: i64, job: Job) { + /// Queue a job for the column `at`, nearest first: its distance (`distance`) + /// times four plus `bias`, which orders a column's jobs. A job without a + /// column (a save) goes ahead of every other. + fn submit(&mut self, at: Option<(i32, i32)>, bias: i64, job: Job) { self.order += 1; + let priority = at.map_or(i64::MIN, |(x, z)| self.distance(x, z) * 4 + bias); self.shared.queue.lock().unwrap().push(Queued { priority, + at, + bias, order: self.order, job, }); self.shared.ready.notify_one(); } + /// Rank the queued jobs again when what their priorities were computed + /// from changed: the centre, the extra areas, the band, or the loading + /// screen (ring order, `Ranges::rings`, whose values are far above the + /// distances used after it). Vanilla's chunk task queue follows the + /// current ticket levels in the same way. Without it, generation queued + /// while the loading screen was up waited behind every job queued after + /// it, and with it the decoration of the chunks around the player: at + /// render distance 32 nothing was decorated until every proto chunk had + /// been generated. + fn requeue(&mut self) { + let basis = (self.center, self.loading, self.band); + if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra { + return; + } + self.queue_basis = (basis, self.extra.clone()); + let mut queue = self.shared.queue.lock().unwrap(); + rank_again(&mut queue, |x, z| self.distance(x, z)); + } fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) { self.generation_scan.exhausted = false; self.decorating -= 1; @@ -682,7 +719,7 @@ impl Streaming { self.saving += 1; // Ahead of every other job: a column generated again meanwhile // would not find them saved. - self.submit(i64::MIN, Job::Save(protos)); + self.submit(None, 0, Job::Save(protos)); } } fn receive_generated(&mut self, mut chunk: Chunk, world: &mut World) { @@ -825,6 +862,7 @@ impl Streaming { return (uploads, Vec::new()); } ExtraCover::refresh(&mut self.cover, &self.extra); + self.requeue(); self.relight_edits(world); let deferred = std::mem::take(&mut self.deferred); let received = std::iter::from_fn(|| self.results.try_recv().ok()); @@ -980,7 +1018,7 @@ impl Streaming { .expect("present proto"); self.finishing.insert((x, z)); self.written.remove(&(x, z)); - self.submit(self.distance(x, z) * 4, Job::Finish(proto)); + self.submit(Some((x, z)), 0, Job::Finish(proto)); } } // Decorate chunks whose 3x3 proto neighbourhood is available, in @@ -1048,7 +1086,7 @@ impl Streaming { w.0 = self.decoration_order.rank_or_append(w.2, w.3); } wanted.sort_unstable(); - for (_, d, x, z) in wanted { + for (_, _, x, z) in wanted { if self.decorating >= workers * 2 { break; } @@ -1087,7 +1125,7 @@ impl Streaming { Err(_) => continue, }; self.decorating += 1; - self.submit(d * 4 + 1, Job::Decorate(x, z, region)); + self.submit(Some((x, z)), 1, Job::Decorate(x, z, region)); } } self.schedule_generation(world, workers); @@ -1107,7 +1145,8 @@ impl Streaming { self.light_snapshots .insert((x, z), self.light_edit_sequence); self.submit( - self.distance(x, z) * 4 + 1, + Some((x, z)), + 1, Job::Light(x, z, region, self.sequence, self.sky_light), ); } @@ -1179,7 +1218,8 @@ impl Streaming { self.sequence += 1; self.meshing.insert((x, sy, z), self.sequence); self.submit( - self.distance(x, z) * 4 + if first { 2 } else { 0 }, + Some((x, z)), + if first { 2 } else { 0 }, Job::Mesh( x, sy, @@ -1218,9 +1258,9 @@ impl Streaming { loading: self.loading, }; if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) { - for (d, x, z) in wanted { + for (_, x, z) in wanted { self.generating.insert((x, z)); - self.submit(d * 4, Job::Generate(x, z)); + self.submit(Some((x, z)), 0, Job::Generate(x, z)); } } } @@ -1734,10 +1774,78 @@ fn section_indexes(mut bits: u32) -> impl Iterator { }) }) } +/// The queued jobs' priorities computed again from each one's column +/// `distance` (`Streaming::submit`). +fn rank_again(queue: &mut BinaryHeap, distance: impl Fn(i32, i32) -> i64) { + if queue.is_empty() { + return; + } + let mut jobs = std::mem::take(queue).into_vec(); + for job in &mut jobs { + if let Some((x, z)) = job.at { + job.priority = distance(x, z) * 4 + job.bias; + } + } + *queue = BinaryHeap::from(jobs); +} #[cfg(test)] mod tests { use super::*; + #[test] + fn queued_jobs_follow_the_centre_and_the_loading_screen() { + let forced = HashSet::new(); + let ranges = |center| Ranges { + center, + radius: 32, + extra: &[], + forced: &forced, + cover: None, + band: None, + }; + let generate = |x, z, priority, order| Queued { + priority, + at: Some((x, z)), + bias: 0, + order, + job: Job::Generate(x, z), + }; + let loading = ranges((0, 0)); + let queued = || { + BinaryHeap::from(vec![ + // Queued while the loading screen was up: ring order. + generate(3, 1, loading.priority(3, 1, true) * 4, 1), + // Queued after it closed: plain distances, far below any ring past the first. + generate(20, 0, loading.priority(20, 0, false) * 4, 2), + Queued { + priority: i64::MIN, + at: None, + bias: 0, + order: 3, + job: Job::Save(Vec::new()), + }, + ]) + }; + let pops = |mut queue: BinaryHeap| { + std::iter::from_fn(move || queue.pop()) + .map(|q| q.at) + .collect::>() + }; + assert_eq!( + pops(queued()), + [None, Some((20, 0)), Some((3, 1))], + "the stale order" + ); + let mut queue = queued(); + rank_again(&mut queue, |x, z| loading.priority(x, z, false)); + assert_eq!(pops(queue), [None, Some((3, 1)), Some((20, 0))]); + // The player moved: the job near the new centre goes first. + let mut queue = BinaryHeap::from(vec![generate(3, 1, 0, 1), generate(20, 0, 1, 2)]); + let moved = ranges((19, 0)); + rank_again(&mut queue, |x, z| moved.priority(x, z, false)); + assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0))); + } + #[test] fn view_distance_is_vanillas_shape() { // ChunkTrackingView.isInViewDistance at render distance 12: a column diff --git a/crates/horizon-client/src/structure_blocks.rs b/crates/horizon-client/src/structure_blocks.rs index 9503294e0..604cf7925 100644 --- a/crates/horizon-client/src/structure_blocks.rs +++ b/crates/horizon-client/src/structure_blocks.rs @@ -63,6 +63,12 @@ pub fn loot_item_with_components(s: &horizon_world::loot::LootStack) -> ItemStac if let Some(level) = s.ominous_amplifier.filter(|l| *l > 0) { item.data_mut().ominous_amplifier = Some(level.clamp(0, 4) as u8); } + // set_stew_effect's `suspicious_stew_effects`. + for (effect, duration) in &s.stew_effects { + item.data_mut() + .stew_effects + .push((Arc::from(effect.as_str()), *duration)); + } item } @@ -108,9 +114,10 @@ pub fn has_pending_map(item: &ItemStack) -> bool { }) } +static LOOT: OnceLock>> = OnceLock::new(); + /// Loot data of the resource root, loaded once per process. pub fn loot_data(root: &Path) -> Option> { - static LOOT: OnceLock>> = OnceLock::new(); LOOT.get_or_init(|| match LootData::load(root) { Ok(data) => Some(Arc::new(data)), Err(e) => { @@ -121,6 +128,54 @@ pub fn loot_data(root: &Path) -> Option> { .clone() } +/// The loot data `loot_data` loaded, if it ran. +pub fn loaded_loot_data() -> Option> { + LOOT.get().cloned().flatten() +} + +/// `RandomizableContainer.unpackLootTable` of a container at `pos`: `table` +/// rolled into `slots`. A missing or zero seed is the level's random in +/// vanilla; Horizon derives one from the position. Without loot data the +/// container stays empty and still saves its table. +pub fn roll_container( + slots: &mut [Option], + loot: Option<&LootData>, + table: String, + saved_seed: Option, + pos: (i32, i32, i32), + biome: &str, +) -> crate::save::RolledLoot { + let seed = saved_seed.filter(|s| *s != 0).unwrap_or_else(|| { + horizon_core::random::position_seed(horizon_core::position::BlockPos::new( + pos.0, pos.1, pos.2, + )) + }); + if let Some(loot) = loot { + // LootTable.fill spreads the items over the container's own size. + let rolled = loot.fill(&table, seed, slots.len(), Some(biome)); + for (slot, stack) in slots.iter_mut().zip(rolled) { + // With what the rolls put on them: enchantments + // (enchant_with_levels in End city chests), potions and an + // ominous bottle's level. + *slot = stack.map(|s| loot_item_with_components(&s)); + } + } + crate::save::RolledLoot { + pos, + table, + seed: saved_seed, + items: crate::save::encode_items(slots), + } +} + +/// A saved `LootTableSeed`, if any. +pub fn loot_seed(nbt: &Tag) -> Option { + match nbt.get("LootTableSeed") { + Some(Tag::Long(seed)) => Some(*seed), + _ => None, + } +} + fn text(nbt: &Compound, key: &str) -> Option { match nbt.get(&JavaString::from(key)) { Some(Tag::String(s)) => Some(s.to_string_lossy()), @@ -149,81 +204,37 @@ fn generated_block_entity(block: &str, nbt: &Compound) -> Option { Some(entity) } -/// Client block entities for the generated block entities of a chunk. +/// Client block entities for the generated block entities of a chunk, and the +/// loot rolled into its containers. pub fn block_entities( chunk: &ProtoChunk, loot: Option<&LootData>, -) -> Vec<((i32, i32, i32), BlockEntity)> { +) -> ( + Vec<((i32, i32, i32), BlockEntity)>, + Vec, +) { let mut out = Vec::new(); + let mut rolled = Vec::new(); for (pos, nbt) in &chunk.block_entities { let [x, y, z] = *pos; let block = chunk.block_name(x, y, z); let Some(mut entity) = generated_block_entity(block, nbt) else { continue; }; - if let (BlockEntity::Chest(slots), Some(table), Some(loot)) = - (&mut entity, text(nbt, "LootTable"), loot) - { - let seed = match nbt.get(&JavaString::from("LootTableSeed")) { - Some(Tag::Long(seed)) => *seed, - // Seed 0 means "the level's random": derive one from the position. - _ => horizon_core::random::position_seed(horizon_core::position::BlockPos::new( - x, y, z, - )), - }; - let biome = chunk.biome_at(x, y, z); - for (slot, stack) in slots - .iter_mut() - .zip(loot.fill(&table, seed, 27, Some(biome))) - { - // With what the rolls put on them: enchantments - // (enchant_with_levels in End city chests), potions and an - // ominous bottle's level. - *slot = stack.map(|s| loot_item_with_components(&s)); - } + if let (BlockEntity::Chest(slots), Some(table)) = (&mut entity, text(nbt, "LootTable")) { + let seed = loot_seed(&Tag::Compound(nbt.clone())); + rolled.push(roll_container( + slots, + loot, + table, + seed, + (x, y, z), + chunk.biome_at(x, y, z), + )); } out.push(((x, y, z), entity)); } - out -} - -/// Chest minecarts placed with a loot table (mineshafts): the table rolled -/// into `Items` as `unpackLootTable` would on first open. -pub fn unpack_entity_loot( - chunk: &ProtoChunk, - mut nbt: horizon_world::nbt::Compound, - loot: Option<&LootData>, -) -> horizon_world::nbt::Compound { - let (Some(table), Some(loot)) = ( - match nbt.get(&JavaString::from("LootTable")) { - Some(Tag::String(s)) => Some(s.to_string_lossy()), - _ => None, - }, - loot, - ) else { - return nbt; - }; - let seed = match nbt.get(&JavaString::from("LootTableSeed")) { - Some(Tag::Long(seed)) => *seed, - _ => 0, - }; - let pos = match nbt.get(&JavaString::from("Pos")) { - Some(Tag::List(p)) => std::array::from_fn(|i| match p.get(i) { - Some(Tag::Double(b)) => f64::from_bits(*b).floor() as i32, - _ => 0, - }), - _ => [0; 3], - }; - let biome = chunk.biome_at(pos[0], pos[1], pos[2]); - let slots: Vec> = loot - .fill(&table, seed, 27, Some(biome)) - .into_iter() - .map(|s| s.map(|s| loot_item_with_components(&s))) - .collect(); - nbt.remove(&JavaString::from("LootTable")); - nbt.remove(&JavaString::from("LootTableSeed")); - nbt.insert("Items".into(), crate::save::encode_items(&slots)); - nbt + (out, rolled) } #[cfg(test)] @@ -243,6 +254,7 @@ mod tests { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), }; // An End city chest's enchant_with_levels roll. let boots = loot_item_with_components(&stack( @@ -270,6 +282,24 @@ mod tests { assert_eq!(arrow.potion(), Some("minecraft:slowness")); } + #[test] + fn a_container_rolled_without_loot_data_saves_its_table() { + let mut slots = vec![None; 27]; + let rolled = roll_container( + &mut slots, + None, + "minecraft:chests/simple_dungeon".into(), + Some(7), + (1, 2, 3), + "minecraft:plains", + ); + let mut saved = horizon_world::nbt::Compound::new(); + saved.insert("Items".into(), crate::save::encode_items(&slots)); + assert!(rolled.restore(&mut saved)); + assert!(!saved.contains_key(&"Items".into())); + assert_eq!(saved.get(&"LootTableSeed".into()), Some(&Tag::Long(7))); + } + #[test] fn exploration_maps_wait_in_custom_data_until_searched() { let mut stack = LootStack { @@ -286,6 +316,7 @@ mod tests { "zoom": 1 })))), ominous_amplifier: None, + stew_effects: Vec::new(), }; let mut item = loot_item(&stack); assert!(has_pending_map(&item)); diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl index c0a2fc6fd..c0cf44bb5 100644 --- a/crates/horizon-client/src/terrain.wgsl +++ b/crates/horizon-client/src/terrain.wgsl @@ -69,6 +69,8 @@ struct TerrainOut { @location(2) color: vec4, @location(3) spherical: f32, @location(4) cylindrical: f32, + // The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`). + @location(5) @interpolate(flat) emission: u32, } // Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units // (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one @@ -121,7 +123,8 @@ fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4, spherical: f32, cylindrical: f32) -> vec4 { // Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout. let color = sprite(in.uv, in.layer & 0x7fffu) * in.color; if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } - return apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical); + // Alpha 0 tells HDR output (hdr.wgsl) a light-emitting block's bright pixels + // may shine. Nothing else reads the frame's alpha. + let emissive = select(1.0, 0.0, in.emission > 0u); + return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical); } @fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4 { let color = sprite(in.uv, in.layer & 0x7fffu) * in.color; @@ -227,8 +233,9 @@ fn sunrise_weight(d: vec3) -> f32 { } return best; } -@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4 { - let d = view_ray(in.ndc); +// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input: +// gfx/native_sky.rs builds that stage from this section). +fn sky_along_ray(d: vec3) -> vec3 { var color = globals.fog_color.rgb; let dist = disc_distance(d, 16.0); if dist.x >= 0.0 { @@ -238,7 +245,10 @@ fn sunrise_weight(d: vec3) -> f32 { if w > 0.0 { color = mix(color, globals.sunrise.rgb, w * globals.sunrise.a); } - return vec4(color, 1.0); + return color; +} +@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4 { + return vec4(sky_along_ray(view_ray(in.ndc)), 1.0); } @fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4 { let d = view_ray(in.ndc); diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index a6967816a..87bd05fd0 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -192,7 +192,7 @@ pub fn list_worlds(saves: &Path) -> Vec { commands: matches!(data.get("allowCommands"), Some(horizon_world::nbt::Tag::Byte(v)) if *v != 0), version: data.get("Version").and_then(|v| v.get("Name")) .and_then(|v| match v { horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()), _ => None }) - .unwrap_or_else(|| "Unknown".into()), + .unwrap_or_else(|| lang::get_or("selectWorld.versionUnknown", "Unknown")), }) }) .collect(); @@ -272,7 +272,7 @@ impl Title { } pub(crate) fn new_world() -> Mode { Mode::Create { - name: "New World".into(), + name: lang::get_or("selectWorld.newWorld", "New World"), seed: String::new(), creative: false, focus: 0, @@ -867,7 +867,13 @@ impl Title { } Mode::Select { worlds, selected } => { crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false); - centered(atlas, batch, "Select World", 8.0, u32::MAX); + centered( + atlas, + batch, + &lang::get_or("selectWorld.title", "Select World"), + 8.0, + u32::MAX, + ); self.caret.borrow_mut().draw( batch, atlas, @@ -1113,7 +1119,8 @@ impl Title { Mode::Multiplayer(screen) => screen.draw( atlas, batch, - (self.started.elapsed().as_millis() / 300).is_multiple_of(2), + &mut self.caret.borrow_mut(), + self.started, cursor, ), } @@ -1408,8 +1415,7 @@ impl Title { mode => caret_field(mode, self.search_focused, &mut self.search), } } - /// Whether the focused field edits at its cursor (every field but the - /// server screens', which still append). + /// Whether the focused field edits at its cursor. pub fn has_caret_field(&mut self) -> bool { caret_field(&mut self.mode, self.search_focused, &mut self.search).is_some() } @@ -1429,15 +1435,9 @@ impl Title { } /// EditBox.charTyped / insertText at the cursor. pub fn type_text(&mut self, text: &str) { - if let Mode::Multiplayer(screen) = &mut self.mode { - return screen.type_text(text); - } self.edit_field(|edit| edit.insert_text(text)); } pub fn backspace(&mut self) { - if let Mode::Multiplayer(screen) = &mut self.mode { - return screen.backspace(); - } self.field_key(winit::keyboard::KeyCode::Backspace, false, false); } /// EditBox.keyPressed: moving and deleting in the focused field. @@ -1465,6 +1465,7 @@ impl Title { Mode::Create { focus, options, .. } => create_world::field_rect(w, h, options.tab) .filter(|(id, _)| id == focus) .map(|(_, rect)| rect), + Mode::Multiplayer(screen) => screen.focused_box((w, h)), _ => None, } } @@ -1617,8 +1618,9 @@ const SEARCH: crate::clipboard::FieldId = ("world.search", 0); const EDIT_NAME: crate::clipboard::FieldId = ("world.edit-name", 0); const WORLD_NAME: crate::clipboard::FieldId = ("world.name", 0); const WORLD_SEED: crate::clipboard::FieldId = ("world.seed", 0); -/// The focused field of the world screens: the list's search, the world -/// name being edited, or Create New World's name or seed. +/// The focused field of the world screens and the server screens: the list's +/// search, a world name or seed, or Add Server, Direct Connection and Join a +/// Friend. fn caret_field<'a>( mode: &'a mut Mode, search_focused: bool, @@ -1638,6 +1640,7 @@ fn caret_field<'a>( 1 => Some((WORLD_SEED, seed, 32)), _ => None, }, + Mode::Multiplayer(screen) => screen.focused_text(), _ => None, } } diff --git a/crates/horizon-client/src/title/tests.rs b/crates/horizon-client/src/title/tests.rs index 572be2ef2..fcaf68225 100644 --- a/crates/horizon-client/src/title/tests.rs +++ b/crates/horizon-client/src/title/tests.rs @@ -824,3 +824,50 @@ fn world_fields_edit_at_their_cursor() { f.title.field_key(KeyCode::Backspace, false, false); assert_eq!(f.title.search, "world"); } + +#[test] +fn server_fields_edit_at_their_cursor() { + use winit::keyboard::KeyCode; + let mut f = Fixture::new(); + f.title.mode = Mode::Multiplayer(crate::multiplayer::Screen::Edit { + index: None, + name: "Minecraft Server".into(), + address: String::new(), + focus: 1, + packs: None, + }); + f.title.type_text("localhost"); + for _ in 0..4 { + assert!(f.title.field_key(KeyCode::ArrowLeft, false, false)); + } + f.title.type_text("X"); + let Mode::Multiplayer(crate::multiplayer::Screen::Edit { address, .. }) = &f.title.mode else { + panic!("add server"); + }; + assert_eq!(address, "localXhost"); + // Shift+Home selects back to the start; typing replaces the selection. + f.title.field_key(KeyCode::Home, false, true); + f.title.type_text("Y"); + let Mode::Multiplayer(crate::multiplayer::Screen::Edit { address, .. }) = &f.title.mode else { + panic!("add server"); + }; + assert_eq!(address, "Yhost"); + // Tab moves to the name, which edits at its own cursor. + f.title.tab(); + f.title.field_key(KeyCode::ArrowLeft, false, false); + f.title.type_text("X"); + let Mode::Multiplayer(crate::multiplayer::Screen::Edit { name, .. }) = &f.title.mode else { + panic!("add server"); + }; + assert_eq!(name, "Minecraft ServeXr"); + f.title.mode = Mode::Multiplayer(crate::multiplayer::Screen::Direct { + address: String::new(), + }); + f.title.type_text("one two"); + // Ctrl+Backspace deletes the word before the caret. + f.title.field_key(KeyCode::Backspace, true, false); + let Mode::Multiplayer(crate::multiplayer::Screen::Direct { address }) = &f.title.mode else { + panic!("direct"); + }; + assert_eq!(address, "one "); +} diff --git a/crates/horizon-client/src/ui.rs b/crates/horizon-client/src/ui.rs index 1954a29dc..e034f2b39 100644 --- a/crates/horizon-client/src/ui.rs +++ b/crates/horizon-client/src/ui.rs @@ -658,6 +658,8 @@ pub struct EditBox { display: usize, pub max_length: usize, pub bordered: bool, + /// `MultiLineEditBox`: a book page keeps line breaks. A single-line box strips them. + pub multiline: bool, pub x: f32, pub y: f32, pub width: f32, @@ -669,7 +671,7 @@ pub struct EditBox { pub text_color: u32, } /// The focused text field of a screen that keeps its values as `String`s -/// (the world list's search, world names, seeds): the `EditBox` state that +/// (the world list's search, world names, seeds, and the server screens): the `EditBox` state that /// outlives a frame, its cursor, selection and scroll. #[derive(Clone, Debug)] pub struct Caret { @@ -853,6 +855,38 @@ pub fn word_position(value: &str, dir: isize, from: usize) -> usize { } pos } +/// `MultilineTextField.getPreviousWord` / `getNextWord`'s `beginIndex` (a book page): any +/// whitespace, line breaks included, separates words, and the search starts from the cursor +/// clamped to the last character, as vanilla's does. +pub fn multiline_word_position(value: &str, dir: isize, from: usize) -> usize { + let chars: Vec = value.chars().collect(); + if chars.is_empty() { + return 0; + } + let mut pos = from.min(chars.len() - 1); + if dir < 0 { + while pos > 0 && java_whitespace(chars[pos - 1]) { + pos -= 1; + } + while pos > 0 && !java_whitespace(chars[pos - 1]) { + pos -= 1; + } + } else { + while pos < chars.len() && !java_whitespace(chars[pos]) { + pos += 1; + } + while pos < chars.len() && java_whitespace(chars[pos]) { + pos += 1; + } + } + pos +} +/// `Character.isWhitespace`: Unicode spaces and line breaks but not the no-break spaces, plus +/// the ASCII separators U+001C to U+001F. +fn java_whitespace(c: char) -> bool { + (c.is_whitespace() && !matches!(c, '\u{85}' | '\u{a0}' | '\u{2007}' | '\u{202f}')) + || ('\u{1c}'..='\u{1f}').contains(&c) +} /// `EditBox.deleteWords(dir)` for an append-only field, whose caret is at the /// end: removes the word in that direction. True when the value changed. pub fn delete_words(value: &mut String, dir: isize) -> bool { @@ -887,6 +921,7 @@ impl EditBox { display: 0, max_length: 32, bordered: true, + multiline: false, x, y, width, @@ -917,6 +952,10 @@ impl EditBox { pub fn reset_blink(&mut self) { self.focused_at = Instant::now(); } + /// The caret is showing (`EditBox`'s blink, on for 300 ms of every 600). + pub fn cursor_visible(&self) -> bool { + self.focused && (self.focused_at.elapsed().as_millis() / 300).is_multiple_of(2) + } pub fn set_value(&mut self, value: &str) { self.value = value.chars().take(self.max_length).collect(); self.move_cursor_to_end(false); @@ -934,7 +973,7 @@ impl EditBox { /// EditBox.insertText: replace the selection, respecting the maximum length. pub fn insert_text(&mut self, text: &str) { let (a, b) = self.selected(); - let filtered = crate::clipboard::filter_text(text, false); + let filtered = crate::clipboard::filter_text(text, self.multiline); let room = self .max_length .saturating_sub(char_len(&self.value) - (b - a)); @@ -960,9 +999,13 @@ impl EditBox { pub fn word_position(&self, dir: isize) -> usize { self.word_position_from(dir, self.cursor) } - /// EditBox.getWordPosition(dir, from). + /// EditBox.getWordPosition(dir, from), or MultilineTextField's word for a book page. fn word_position_from(&self, dir: isize, from: usize) -> usize { - word_position(&self.value, dir, from) + if self.multiline { + multiline_word_position(&self.value, dir, from) + } else { + word_position(&self.value, dir, from) + } } pub fn set_cursor(&mut self, pos: usize, select: bool) { self.cursor = pos.min(char_len(&self.value)); @@ -1492,6 +1535,17 @@ mod tests { assert_eq!(word_position("hi", 1, 99), 2); } #[test] + fn multiline_words_split_at_any_whitespace() { + // Ctrl+Backspace at the end of `one\ntwo` deletes `two` only. + assert_eq!(multiline_word_position("one\ntwo", -1, 7), 4); + // From the end the search starts at the last character, as vanilla's does. + assert_eq!(multiline_word_position("one\n", -1, 4), 0); + assert_eq!(multiline_word_position("a b", -1, 3), 0); + assert_eq!(multiline_word_position("one\n\ntwo", 1, 0), 5); + assert_eq!(multiline_word_position("one\u{a0}two", 1, 0), 7); + assert_eq!(multiline_word_position("", -1, 0), 0); + } + #[test] fn delete_words_removes_the_word_before_the_end() { let mut v = "stone bricks".to_string(); assert!(delete_words(&mut v, -1)); diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index c890213c5..923818a08 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -158,6 +158,12 @@ impl VanillaTerrain { chunk.terrain.min_y().max(MIN_Y), (chunk.terrain.min_y() + chunk.terrain.height()).min(MIN_Y + HEIGHT), ); + out.structure_refs = chunk + .structures() + .into_iter() + .map(|(id, start, _, _)| (Arc::from(id), (start.x, start.z))) + .collect(); + out.structure_starts = chunk.own_starts(); let mut values = Box::new([0u32; 4096]); for si in 0..SECTIONS as i32 { let base = MIN_Y + si * 16; @@ -339,7 +345,9 @@ pub fn shared_data( if let Some((_, data)) = cache.iter().find(|(path, _)| path == dir) { return Ok(data.clone()); } + let started = std::time::Instant::now(); let data = GeneratorData::load(dir, registry)?; + log::info!("Horizon: worldgen data loaded in {:?}", started.elapsed()); cache.push((dir.to_owned(), data.clone())); Ok(data) } @@ -434,6 +442,14 @@ impl ChunkGenerator for VanillaTerrain { .iter() .map(|(pos, nbt)| { let mut nbt = nbt.clone(); + // Placed data can lack its type (a brushable block's loot table + // alone); vanilla saves every pending block entity with its id, + // and skips one without when the chunk finishes. + if let Some(id) = + crate::lan::chunks::block_entity_type(chunk.block_name(pos[0], pos[1], pos[2])) + { + nbt.insert("id".into(), Tag::String(id.into())); + } for (key, v) in ["x", "y", "z"].into_iter().zip(pos) { nbt.insert(key.into(), Tag::Int(*v)); } @@ -531,7 +547,9 @@ impl ChunkGenerator for VanillaTerrain { VanillaProto::Failed(x, z) => return Chunk::empty(x, z), }; let mut out = self.column(&chunk); - out.block_entities = crate::structure_blocks::block_entities(&chunk, self.loot.as_deref()); + let (block_entities, rolled_loot) = + crate::structure_blocks::block_entities(&chunk, self.loot.as_deref()); + out.block_entities = block_entities; // 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. @@ -555,26 +573,20 @@ impl ChunkGenerator for VanillaTerrain { Some(Tag::Compound(nbt)) }) .collect(); - if !unmodelled.is_empty() { + if !unmodelled.is_empty() || !rolled_loot.is_empty() { let mut extras = out .saved .take() .map(Arc::unwrap_or_clone) .unwrap_or_default(); extras.block_entities = unmodelled; + extras.rolled_loot = rolled_loot; out.saved = Some(Arc::new(extras)); } - out.structure_refs = chunk - .structures() - .into_iter() - .map(|(id, start, _, _)| (Arc::from(id), (start.x, start.z))) - .collect(); let entities = std::mem::take(&mut chunk.entities); + // Chest minecarts keep their loot table; the game rolls it on load. out.entities = entities .into_iter() - .map(|nbt| { - crate::structure_blocks::unpack_entity_loot(&chunk, nbt, self.loot.as_deref()) - }) .map(horizon_world::nbt::Tag::Compound) .collect(); for s in &mut out.sections { diff --git a/crates/horizon-client/src/world.rs b/crates/horizon-client/src/world.rs index d73956d85..83083eba2 100644 --- a/crates/horizon-client/src/world.rs +++ b/crates/horizon-client/src/world.rs @@ -109,7 +109,9 @@ pub struct Weather { pub thunder_level: (f32, f32), } impl Weather { - pub fn tick(&mut self, random: &mut horizon_core::random::LegacyRandom) { + /// The timers of `advanceWeatherCycle`, which run only while + /// `advance_weather` is on. + pub fn tick_timers(&mut self, random: &mut horizon_core::random::LegacyRandom) { use horizon_core::random::RandomSource; let mut uniform = |min: i32, max: i32| min + random.next_int_bound(max - min + 1).unwrap_or(0); @@ -141,6 +143,10 @@ impl Weather { self.rain_time = uniform(12000, 180000); } } + } + /// The rain and thunder level fade at the end of `advanceWeatherCycle`, + /// which runs whatever `advance_weather` says, so `/weather` still shows. + pub fn tick_levels(&mut self) { self.thunder_level.0 = self.thunder_level.1; self.thunder_level.1 = (self.thunder_level.1 + if self.thundering { 0.01 } else { -0.01 }).clamp(0.0, 1.0); diff --git a/crates/horizon-client/src/world_post.wgsl b/crates/horizon-client/src/world_post.wgsl index 7332213c5..ed1a7a094 100644 --- a/crates/horizon-client/src/world_post.wgsl +++ b/crates/horizon-client/src/world_post.wgsl @@ -18,7 +18,8 @@ struct PostOut { } @fragment fn fs_copy(in: PostOut) -> @location(0) vec4 { - return vec4(textureSampleLevel(world, world_sampler, in.uv, 0.0).rgb, 1.0); + // The alpha carries the emissive mark through to HDR output (hdr.wgsl). + return textureSampleLevel(world, world_sampler, in.uv, 0.0); } fn luma(c: vec3) -> f32 { @@ -28,7 +29,8 @@ fn luma(c: vec3) -> f32 { // FXAA 3.11 (console quality): blend along the local edge direction. @fragment fn fs_fxaa(in: PostOut) -> @location(0) vec4 { let texel = 1.0 / vec2(textureDimensions(world)); - let rgb_m = textureSampleLevel(world, world_sampler, in.uv, 0.0).rgb; + let centre = textureSampleLevel(world, world_sampler, in.uv, 0.0); + let rgb_m = centre.rgb; let nw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, -0.5) * texel, 0.0).rgb); let ne = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, -0.5) * texel, 0.0).rgb); let sw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, 0.5) * texel, 0.0).rgb); @@ -37,7 +39,7 @@ fn luma(c: vec3) -> f32 { let lo = min(m, min(min(nw, ne), min(sw, se))); let hi = max(m, max(max(nw, ne), max(sw, se))); if hi - lo < max(0.0312, hi * 0.125) { - return vec4(rgb_m, 1.0); + return centre; } var dir = vec2(-((nw + ne) - (sw + se)), (nw + sw) - (ne + se)); let reduce = max((nw + ne + sw + se) * 0.25 * 0.125, 1.0 / 128.0); @@ -49,7 +51,7 @@ fn luma(c: vec3) -> f32 { + textureSampleLevel(world, world_sampler, in.uv + dir * 0.5, 0.0).rgb); let lb = luma(b); if lb < lo || lb > hi { - return vec4(a, 1.0); + return vec4(a, centre.a); } - return vec4(b, 1.0); + return vec4(b, centre.a); } diff --git a/crates/horizon-client/tests/generation_saves.rs b/crates/horizon-client/tests/generation_saves.rs index 30b971e05..2f8c2ab6b 100644 --- a/crates/horizon-client/tests/generation_saves.rs +++ b/crates/horizon-client/tests/generation_saves.rs @@ -315,3 +315,70 @@ fn saved_proto_chunks_restore_exactly() { drop(save); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn saved_proto_block_entities_keep_their_id() { + use horizon_client::{ + save::{Persistent, SaveDir}, + vanillagen::VanillaTerrain, + }; + use horizon_world::{ + nbt::{Limits, Tag}, + region::RegionFile, + }; + let Some(root) = resources() else { + return; + }; + let assets = Arc::new(Assets::load(&root).unwrap()); + let biomes = Arc::new(Biomes::load(&root).unwrap()); + let terrain = VanillaTerrain::for_dimension( + &root.join("worldgen"), + assets.registry, + &biomes, + 12345, + "minecraft:overworld", + ) + .unwrap(); + let dir = std::env::temp_dir().join(format!("horizon-proto-ids-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let save = Arc::new(SaveDir::new(dir.clone())); + let generator = Persistent { + inner: Arc::new(terrain), + save: save.clone(), + registry: assets.clone(), + biomes, + }; + let mut worker = generator.worker(); + // A cold ocean ruin of seed 12345: its suspicious gravel is placed with a + // loot table and no type. + let keys: Vec<(i32, i32)> = (0..9).map(|i| (43 + i % 3 - 1, 21 + i / 3 - 1)).collect(); + let mut region: [Proto; 9] = + std::array::from_fn(|i| generator.proto(&mut worker, keys[i].0, keys[i].1)); + generator.decorate(&mut worker, &mut region); + generator.save_protos(®ion.iter().collect::>()); + save.flush().unwrap(); + let mut file = RegionFile::open(dir.join("region/r.1.0.mca")).unwrap(); + let mut suspicious = 0; + for &(x, z) in &keys { + let Some(record) = file + .read(horizon_core::position::ChunkPos::new(x, z)) + .unwrap() + else { + continue; + }; + let root = record.nbt(Limits::default()).unwrap(); + let Some(Tag::List(entities)) = root.tag.get("block_entities") else { + continue; + }; + for e in entities { + assert!(e.get("id").is_some(), "chunk {x} {z}: {e:?}"); + if let Some(Tag::String(table)) = e.get("LootTable") { + suspicious += usize::from(table.to_string_lossy().contains("archaeology/")); + } + } + } + assert!(suspicious > 0, "the ruin placed no suspicious blocks"); + drop(generator); + drop(save); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/horizon-hdr/Cargo.toml b/crates/horizon-hdr/Cargo.toml new file mode 100644 index 000000000..e83e590e8 --- /dev/null +++ b/crates/horizon-hdr/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "horizon-hdr" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version = "1.85" +description = "HDR metadata for the swapchain: the Vulkan calls the crates that forbid unsafe code can't make" + +[dependencies] +wgpu = { version = "30.0.1", default-features = false, features = ["vulkan"] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +# The version wgpu-hal already uses. +ash = { version = "0.38", default-features = false } + +[lints] +workspace = true diff --git a/crates/horizon-hdr/src/lib.rs b/crates/horizon-hdr/src/lib.rs new file mode 100644 index 000000000..cda61868e --- /dev/null +++ b/crates/horizon-hdr/src/lib.rs @@ -0,0 +1,95 @@ +//! HDR metadata for the swapchain: the Vulkan calls the game crate, which +//! forbids unsafe code, can't make itself. wgpu sets no HDR metadata, and +//! without it a compositor may clip or tone-map everything above 1.0 (80 nits) +//! of an scRGB swapchain. + +/// Opens a Vulkan device with `VK_EXT_hdr_metadata` too, when the GPU offers +/// it, so [`set_metadata`] can tell the compositor the real peak brightness. +/// `None` leaves opening to wgpu. +#[cfg(not(target_os = "macos"))] +pub fn open_vulkan_device( + adapter: &wgpu::Adapter, + desc: &wgpu::DeviceDescriptor<'_>, +) -> Option> { + use wgpu::hal::api::Vulkan; + let open = { + // Safety: only read and opened from here; dropped before wgpu takes it back. + let hal = unsafe { adapter.as_hal::() }?; + let instance = hal.shared_instance().raw_instance(); + // Safety: the adapter's own physical device on its own instance. + let offered = + unsafe { instance.enumerate_device_extension_properties(hal.raw_physical_device()) } + .ok()? + .iter() + .any(|e| e.extension_name_as_c_str() == Ok(ash::ext::hdr_metadata::NAME)); + if !offered { + return None; + } + // Safety: what wgpu's own request_device does, with one more extension. + unsafe { + hal.open_with_callback( + desc.required_features, + &desc.required_limits, + &desc.memory_hints, + Some(Box::new( + |args: wgpu::hal::vulkan::CreateDeviceCallbackArgs| { + args.extensions.push(ash::ext::hdr_metadata::NAME); + }, + )), + ) + } + .ok()? + }; + // Safety: opened from this adapter with exactly `desc`'s features. + Some(unsafe { adapter.create_device_from_hal(open, desc) }) +} + +/// Tells the compositor, through `VK_EXT_hdr_metadata`, that the swapchain's +/// content is BT.709 and peaks at `peak` nits. Needs a device from +/// `open_vulkan_device` and a configured surface; does nothing otherwise. +pub fn set_metadata( + device: &wgpu::Device, + surface: &wgpu::Surface<'_>, + paper_white: f32, + peak: f32, +) { + #[cfg(not(target_os = "macos"))] + { + use wgpu::hal::api::Vulkan; + // Safety: read-only access to handles wgpu keeps alive while these guards live. + let (Some(hal_device), Some(hal_surface)) = + (unsafe { device.as_hal::() }, unsafe { + surface.as_hal::() + }) + else { + return; + }; + if !hal_device + .enabled_device_extensions() + .contains(&ash::ext::hdr_metadata::NAME) + { + return; + } + let Some(swapchain) = hal_surface.raw_native_swapchain() else { + return; + }; + let xy = |x, y| ash::vk::XYColorEXT { x, y }; + let metadata = ash::vk::HdrMetadataEXT::default() + .display_primary_red(xy(0.64, 0.33)) + .display_primary_green(xy(0.30, 0.60)) + .display_primary_blue(xy(0.15, 0.06)) + .white_point(xy(0.3127, 0.3290)) + .max_luminance(peak) + .min_luminance(0.0) + .max_content_light_level(peak) + .max_frame_average_light_level(paper_white.min(peak)); + let loader = ash::ext::hdr_metadata::Device::new( + hal_device.shared_instance().raw_instance(), + hal_device.raw_device(), + ); + // Safety: a live swapchain of this device, one metadata entry for it. + unsafe { loader.set_hdr_metadata(&[swapchain], &[metadata]) }; + } + #[cfg(target_os = "macos")] + let _ = (device, surface, paper_white, peak); +} diff --git a/crates/horizon-mod-registry/Cargo.lock b/crates/horizon-mod-registry/Cargo.lock index 44ef1e078..59bd37134 100644 --- a/crates/horizon-mod-registry/Cargo.lock +++ b/crates/horizon-mod-registry/Cargo.lock @@ -17,12 +17,24 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -80,6 +92,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "block-buffer" version = "0.10.4" @@ -95,6 +128,26 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bytes" version = "1.12.1" @@ -117,6 +170,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -147,6 +215,12 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -165,7 +239,7 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -187,7 +261,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -220,6 +294,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -236,6 +322,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -302,11 +400,46 @@ dependencies = [ "wasip2", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] [[package]] name = "hmac" @@ -327,6 +460,7 @@ dependencies = [ "hmac", "horizon-mods", "http-body-util", + "rusqlite", "serde", "serde_json", "sha2", @@ -340,12 +474,27 @@ dependencies = [ name = "horizon-mods" version = "0.1.0" dependencies = [ + "horizon-shader-api", "log", "minisign-verify", "serde", "serde_json", "sha2", "toml", + "wasmparser", + "zip", +] + +[[package]] +name = "horizon-shader-api" +version = "0.1.0" +dependencies = [ + "bytemuck", + "naga", + "serde", + "serde_json", + "sha2", + "toml", "zip", ] @@ -539,7 +688,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -554,6 +705,23 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.3" @@ -634,6 +802,54 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash", + "thiserror", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -652,6 +868,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "potential_utf" version = "0.1.6" @@ -699,6 +921,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustls" version = "0.23.46" @@ -752,6 +994,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -779,7 +1027,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -894,6 +1142,15 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -906,6 +1163,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -931,7 +1199,27 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", ] [[package]] @@ -968,7 +1256,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -1049,6 +1337,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -1090,6 +1384,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1111,6 +1411,19 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasmparser" +version = "0.254.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -1254,10 +1567,30 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -1275,7 +1608,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", "synstructure", ] @@ -1315,7 +1648,7 @@ checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] diff --git a/crates/horizon-mod-registry/Cargo.toml b/crates/horizon-mod-registry/Cargo.toml index affc08f62..959e84f91 100644 --- a/crates/horizon-mod-registry/Cargo.toml +++ b/crates/horizon-mod-registry/Cargo.toml @@ -23,6 +23,8 @@ hmac = "0.12" sha2 = "0.10" base64 = "0.22" getrandom = "0.3" +# Ratings: one SQLite file on a small volume, as the telemetry server keeps its data. +rusqlite = { version = "0.37", features = ["bundled"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/crates/horizon-mod-registry/Dockerfile b/crates/horizon-mod-registry/Dockerfile index 13522165a..ee2065008 100644 --- a/crates/horizon-mod-registry/Dockerfile +++ b/crates/horizon-mod-registry/Dockerfile @@ -4,11 +4,15 @@ WORKDIR /src # The root manifest only for the shared crate's workspace settings (edition, lints). COPY Cargo.toml Cargo.toml COPY crates/horizon-mods crates/horizon-mods +# horizon-mods' optional dependency: cargo needs its manifest even with the feature off. +COPY crates/horizon-shader-api crates/horizon-shader-api COPY crates/horizon-mod-registry crates/horizon-mod-registry RUN cargo build --release --locked --manifest-path crates/horizon-mod-registry/Cargo.toml FROM debian:stable-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=build /src/crates/horizon-mod-registry/target/release/horizon-mod-registry /usr/local/bin/ -USER nobody -CMD ["horizon-mod-registry"] +ENV REGISTRY_DB=/data/registry.sqlite3 +# Railway mounts the ratings' volume at /data owned by root: hand it to nobody, then run as nobody (setpriv is +# util-linux's, in every Debian image). +CMD ["sh", "-c", "mkdir -p /data && chown nobody:nogroup /data && exec setpriv --reuid=nobody --regid=nogroup --clear-groups horizon-mod-registry"] diff --git a/crates/horizon-mod-registry/README.md b/crates/horizon-mod-registry/README.md index f7cdc68c5..d609e6739 100644 --- a/crates/horizon-mod-registry/README.md +++ b/crates/horizon-mod-registry/README.md @@ -7,20 +7,26 @@ entry's fields), uploads it to the `staging` release of request adding or updating `mods/.toml`. That repository's CI checks it again, with a trial run of a mod in the sandbox; reviewers decide, and merging publishes. Formats and rules: [docs/api/mod-index.md](../../docs/api/mod-index.md). -It keeps no data: a session is a signed cookie holding the GitHub login, and the user's OAuth token is used once, to -read that login, and dropped. The sign-in asks for no scope. +Signed-in players also rate published projects, 1 to 5 stars: one rating per GitHub account and project, which they can +change, never their own project's. That is the only data it keeps, in one SQLite file (`REGISTRY_DB`); it only ever +shows totals, never who rated. A session is a signed cookie holding the GitHub login and id, and the user's OAuth token +is used once, to read them, and dropped. The sign-in asks for no scope. | Route | | | --- | --- | -| `GET /api/mods/auth/login` | Starts the GitHub sign-in | -| `GET /api/mods/auth/callback` | GitHub's redirect: sets the session, back to `/mods/submit` | +| `GET /api/mods/auth/login` | Starts the GitHub sign-in; `?back=/mods/` (or `/mods`) comes back to that page | +| `GET /api/mods/auth/callback` | GitHub's redirect: sets the session, back to `/mods/submit` or the `back` page | | `POST /api/mods/auth/logout` | Ends it | | `GET /api/mods/me` | `{login, csrf}`, or 401 | | `POST /api/mods/submit` | Multipart: `entry` (JSON: `id kind name summary description authors license source icon key signature version game changelog`) and `file`; `{pull_request}` or `{error}` | +| `GET /api/mods/ratings` | `{projects: {: {average, count}}}` for every rated project; cached a minute | +| `GET /api/mods//rating` | The signed-in account's own `{stars}` (null when it hasn't rated), or 401 | +| `POST /api/mods//rating` | JSON `{stars}`, 1 to 5: sets or changes it; `{stars, average, count}` or `{error}` | | `GET /healthz` | `ok` | -A submission must come from `REGISTRY_PUBLIC_URL`'s origin with the session's `X-CSRF-Token`. Six per account and sixty -in all per hour. Only an entry's `owners` publish its updates; a key change, an icon in the repository or anything else +A submission or a rating must come from `REGISTRY_PUBLIC_URL`'s origin with the session's `X-CSRF-Token`. Six +submissions per account and sixty in all per hour; thirty ratings per account and 600 in all. A project's owners can't +rate it, and only projects on the registry's `main` can be rated. Only an entry's `owners` publish its updates; a key change, an icon in the repository or anything else is a pull request by hand. ## Running it @@ -28,7 +34,7 @@ is a pull request by hand. ```sh cargo test # against a mock GitHub (tests/submit.rs) REGISTRY_PUBLIC_URL=http://localhost:4173 REGISTRY_SESSION_KEY=... GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... \ - GITHUB_BOT_TOKEN=... REGISTRY_REPO=you/horizon-mods-test cargo run + GITHUB_BOT_TOKEN=... REGISTRY_REPO=you/horizon-mods-test REGISTRY_DB=/tmp/registry.sqlite3 cargo run ``` | Variable | | @@ -38,10 +44,12 @@ REGISTRY_PUBLIC_URL=http://localhost:4173 REGISTRY_SESSION_KEY=... GITHUB_CLIENT | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | The OAuth app | | `GITHUB_BOT_TOKEN` | Fine-grained, the registry repository only: Contents and Pull requests read and write | | `REGISTRY_REPO` | Default `ludovic111/horizon-mods` | +| `REGISTRY_DB` | The ratings' SQLite file: default `/data/registry.sqlite3`, the volume | | `PORT` | Default 8080 | On Railway it is the service `horizon-mod-registry` of the project `horizon-site`, next to the website: built from -`main` with this `Dockerfile` from the repository root, health check `/healthz`, no public domain and no volume. The +`main` with this `Dockerfile` from the repository root, health check `/healthz`, no public domain, and the volume +`registry-data` at `/data` for the ratings. The website's Caddy sends `/api/mods/*` to it over Railway's private network (`REGISTRY_UPSTREAM` on the website service: `http://horizon-mod-registry.railway.internal:8080`). `railway.json` lists the same settings; Railway reads them from the service, not from the file. diff --git a/crates/horizon-mod-registry/src/lib.rs b/crates/horizon-mod-registry/src/lib.rs index 72970017a..661337c8c 100644 --- a/crates/horizon-mod-registry/src/lib.rs +++ b/crates/horizon-mod-registry/src/lib.rs @@ -2,13 +2,15 @@ //! `playhorizon.dev/api/mods/*` (docs/api/mod-index.md). A creator signs in //! with GitHub, sends a file and its page; the service runs the file's own //! checks, uploads it to the registry's `staging` release and opens a pull -//! request with the entry. Reviewers decide; merging publishes. It keeps no -//! data: sessions are signed cookies, and GitHub holds everything else. +//! request with the entry. Reviewers decide; merging publishes. Signed-in +//! players also rate published projects (`ratings`, its only data: sessions +//! are signed cookies, and GitHub holds everything else). pub mod github; +pub mod ratings; use axum::{ Json, Router, - extract::{DefaultBodyLimit, Multipart, Query, State}, + extract::{DefaultBodyLimit, Multipart, Path as UrlPath, Query, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Redirect, Response}, routing::{get, post}, @@ -20,6 +22,7 @@ use horizon_mods::index::{ self, Kind, registry::{self, Entry, EntryVersion, Sources}, }; +use ratings::Ratings; use serde::Deserialize; use serde_json::json; use sha2::Sha256; @@ -37,6 +40,12 @@ const PER_ACCOUNT: usize = 6; const PER_HOUR: usize = 60; /// The page's JSON. const MAX_FORM: usize = 64 << 10; +/// Ratings set per account per hour, and by everyone. +const RATINGS_PER_ACCOUNT: usize = 30; +const RATINGS_PER_HOUR: usize = 600; +/// How long the ratings' totals are served from memory (and cached by +/// browsers and the launcher). +const TOTALS_FOR: Duration = Duration::from_secs(60); pub struct Config { /// `https://playhorizon.dev`: where the form is, and the only origin it @@ -47,11 +56,14 @@ pub struct Config { pub github: GitHub, /// Where files wait during a submission. pub scratch: PathBuf, + /// The ratings' SQLite file (a volume): `:memory:` keeps them in memory. + pub database: PathBuf, } impl Config { /// `REGISTRY_PUBLIC_URL`, `REGISTRY_SESSION_KEY`, `REGISTRY_REPO`, - /// `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_BOT_TOKEN`. + /// `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_BOT_TOKEN`, + /// `REGISTRY_DB` (default `/data/registry.sqlite3`). pub fn from_env() -> Result { let var = |name: &str| std::env::var(name).map_err(|_| format!("{name} is not set")); let key = var("REGISTRY_SESSION_KEY")?; @@ -69,6 +81,8 @@ impl Config { var("GITHUB_BOT_TOKEN")?, ), scratch: std::env::temp_dir().join("horizon-mod-registry"), + database: std::env::var_os("REGISTRY_DB") + .map_or_else(|| PathBuf::from("/data/registry.sqlite3"), PathBuf::from), }) } @@ -80,17 +94,31 @@ impl Config { #[derive(Clone)] pub struct App(Arc); +type Windows = Mutex>>; + struct Inner { config: Config, - recent: Mutex>>, + /// Submissions in the last hour, by account ("" for everyone's). + recent: Windows, + /// Ratings set in the last hour, the same way. + rated: Windows, + ratings: Mutex, + /// `GET /api/mods/ratings`' body, and when it was made. + totals: Mutex>, } impl App { - pub fn new(config: Config) -> App { - App(Arc::new(Inner { + /// Fails when the ratings' file can't be opened. + pub fn new(config: Config) -> Result { + let ratings = Ratings::open(&config.database) + .map_err(|e| format!("{}: {e}", config.database.display()))?; + Ok(App(Arc::new(Inner { config, - recent: Mutex::new(HashMap::new()), - })) + recent: Mutex::default(), + rated: Mutex::default(), + ratings: Mutex::new(ratings), + totals: Mutex::default(), + }))) } } @@ -101,6 +129,8 @@ pub fn router(app: App) -> Router { .route("/api/mods/auth/callback", get(callback)) .route("/api/mods/auth/logout", post(logout)) .route("/api/mods/me", get(me)) + .route("/api/mods/ratings", get(totals)) + .route("/api/mods/{id}/rating", get(my_rating).post(rate)) .route( "/api/mods/submit", post(submit).layer(DefaultBodyLimit::max( @@ -203,9 +233,29 @@ fn csrf(app: &Inner, headers: &HeaderMap) -> Option { Some(signed.rsplit('.').next()?.chars().take(32).collect()) } -async fn login(State(app): State) -> Response { +#[derive(Deserialize)] +struct Login { + /// The site's page to come back to: `/mods`, `/mods/submit` (the + /// default) or `/mods/`. + back: Option, +} + +/// Whether `path` is a registry page a sign-in may come back to. +fn registry_page(path: &str) -> bool { + match path.strip_prefix("/mods") { + Some("") => true, + Some(rest) => rest.strip_prefix('/').is_some_and(index::valid_id) || rest == "/submit", + None => false, + } +} + +async fn login(State(app): State, Query(q): Query) -> Response { let state = random_hex(); - let signed = sign(&app.0.config.session_key, &state); + let back = q + .back + .filter(|b| registry_page(b)) + .unwrap_or_else(|| "/mods/submit".into()); + let signed = sign(&app.0.config.session_key, &format!("{state}\n{back}")); let url = app .0 .config @@ -229,13 +279,17 @@ async fn callback( headers: HeaderMap, Query(q): Query, ) -> Result { - let expected = cookie(&headers, "hm_state").and_then(|v| unsign(&app.0.config.session_key, &v)); - if expected.as_deref() != Some(q.state.as_str()) { + let started = cookie(&headers, "hm_state").and_then(|v| unsign(&app.0.config.session_key, &v)); + let Some((_, back)) = started + .as_deref() + .and_then(|s| s.split_once('\n')) + .filter(|(state, _)| *state == q.state) + else { return Err(Refusal::new( StatusCode::FORBIDDEN, "the sign-in expired or didn't start here: try again", )); - } + }; let inner = app.0.clone(); let (login, id) = tokio::task::spawn_blocking(move || { inner @@ -250,7 +304,7 @@ async fn callback( &app.0.config.session_key, &format!("{login}\n{id}\n{}", now() + SESSION), ); - let back = format!("{}/mods/submit", app.0.config.public_url); + let back = format!("{}{back}", app.0.config.public_url); let mut response = Redirect::to(&back).into_response(); let headers = response.headers_mut(); headers.append( @@ -283,6 +337,131 @@ async fn me( )) } +fn database(e: rusqlite::Error) -> Refusal { + eprintln!("horizon-mod-registry: ratings: {e}"); + Refusal::new( + StatusCode::INTERNAL_SERVER_ERROR, + "the ratings can't be read right now", + ) +} + +/// A mean as the pages show it: two decimals. +fn rounded(average: f64) -> f64 { + (average * 100.0).round() / 100.0 +} + +/// `GET /api/mods/ratings`: `{"projects": {"": {"average", "count"}}}` for +/// every rated project. Totals only: never who rated. +async fn totals(State(app): State) -> Result { + let mut cached = app.0.totals.lock().unwrap(); + let body = match cached.as_ref().filter(|(at, _)| at.elapsed() < TOTALS_FOR) { + Some((_, body)) => body.clone(), + None => { + let all = app + .0 + .ratings + .lock() + .unwrap() + .summaries() + .map_err(database)?; + let projects: serde_json::Map = all + .into_iter() + .map(|(id, s)| (id, json!({"average": rounded(s.average), "count": s.count}))) + .collect(); + let body = json!({ "projects": projects }).to_string(); + *cached = Some((Instant::now(), body.clone())); + body + } + }; + Ok(( + [ + (header::CONTENT_TYPE, "application/json"), + (header::CACHE_CONTROL, "public, max-age=60"), + ], + body, + ) + .into_response()) +} + +/// `GET /api/mods/{id}/rating`: the signed-in account's own rating, +/// `{"stars": 1..5}` or `{"stars": null}`. +async fn my_rating( + State(app): State, + headers: HeaderMap, + UrlPath(id): UrlPath, +) -> Result, Refusal> { + let (_, account) = session(&app.0, &headers) + .ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "not signed in"))?; + let stars = app + .0 + .ratings + .lock() + .unwrap() + .mine(&id, account) + .map_err(database)?; + Ok(Json(json!({ "stars": stars }))) +} + +#[derive(Deserialize)] +struct Rating { + stars: u8, +} + +/// `POST /api/mods/{id}/rating` with `{"stars": 1..5}`: sets or changes the +/// signed-in account's rating of a published project, not its own. Answers +/// the project's new totals and `stars`. +async fn rate( + State(app): State, + headers: HeaderMap, + UrlPath(id): UrlPath, + Json(rating): Json, +) -> Result, Refusal> { + let inner = app.0.clone(); + let (login, account) = signed_in_here(&inner, &headers)?; + if !(1..=5).contains(&rating.stars) { + return Err(bad("a rating is 1 to 5 stars")); + } + if !index::valid_id(&id) { + return Err(Refusal::new(StatusCode::NOT_FOUND, "no such project")); + } + if !allow(&inner.rated, &login, RATINGS_PER_ACCOUNT, RATINGS_PER_HOUR) { + return Err(Refusal::new( + StatusCode::TOO_MANY_REQUESTS, + "too many ratings this hour: try again later", + )); + } + let lookup = inner.clone(); + let wanted = id.clone(); + let entry = tokio::task::spawn_blocking(move || lookup.config.github.entry(&wanted)) + .await + .map_err(|_| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, "the check crashed"))? + .map_err(github_down)?; + let Some((entry, _)) = entry else { + return Err(Refusal::new( + StatusCode::NOT_FOUND, + format!("{id} isn't published in the registry"), + )); + }; + if entry.owners.iter().any(|o| o.eq_ignore_ascii_case(&login)) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "you publish this project: its players rate it", + )); + } + let summary = { + let ratings = inner.ratings.lock().unwrap(); + ratings + .rate(&id, account, rating.stars, now()) + .map_err(database)?; + ratings.summary(&id).map_err(database)? + }; + *inner.totals.lock().unwrap() = None; + let (average, count) = summary.map_or((0.0, 0), |s| (rounded(s.average), s.count)); + Ok(Json( + json!({"stars": rating.stars, "average": average, "count": count}), + )) +} + /// The page a submission fills in: a project's fields, and the version's. #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -322,45 +501,56 @@ fn take(window: &mut VecDeque, max: usize) -> bool { window.len() < max } +/// A change sent by the site's own pages: from its origin, signed in, with +/// the session's CSRF token. The account: (login, id). +fn signed_in_here(app: &Inner, headers: &HeaderMap) -> Result<(String, u64), Refusal> { + let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()); + if origin != Some(app.config.public_url.as_str()) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "send it from the registry's pages", + )); + } + let account = session(app, headers) + .ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "sign in with GitHub first"))?; + let sent = headers.get("x-csrf-token").and_then(|v| v.to_str().ok()); + if sent.is_none() || sent.map(str::to_owned) != csrf(app, headers) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "reload the page and try again", + )); + } + Ok(account) +} + +/// Counts one more for `login` and for everyone, unless either is at its +/// hourly limit. +fn allow(windows: &Windows, login: &str, per_account: usize, per_hour: usize) -> bool { + let mut windows = windows.lock().unwrap(); + let account = login.to_lowercase(); + let ok = take(windows.entry(String::new()).or_default(), per_hour) + && take(windows.entry(account.clone()).or_default(), per_account); + if ok { + let at = Instant::now(); + windows.entry(String::new()).or_default().push_back(at); + windows.entry(account).or_default().push_back(at); + } + ok +} + async fn submit( State(app): State, headers: HeaderMap, mut multipart: Multipart, ) -> Result, Refusal> { let inner = app.0.clone(); - let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()); - if origin != Some(inner.config.public_url.as_str()) { + let (login, user_id) = signed_in_here(&inner, &headers)?; + if !allow(&inner.recent, &login, PER_ACCOUNT, PER_HOUR) { return Err(Refusal::new( - StatusCode::FORBIDDEN, - "submit from the registry's form", + StatusCode::TOO_MANY_REQUESTS, + "too many submissions this hour: try again later", )); } - let (login, user_id) = session(&inner, &headers) - .ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "sign in with GitHub first"))?; - let sent = headers.get("x-csrf-token").and_then(|v| v.to_str().ok()); - if sent.is_none() || sent.map(str::to_owned) != csrf(&inner, &headers) { - return Err(Refusal::new( - StatusCode::FORBIDDEN, - "reload the form and try again", - )); - } - { - let mut recent = inner.recent.lock().unwrap(); - let ok = take(recent.entry(String::new()).or_default(), PER_HOUR) - && take(recent.entry(login.to_lowercase()).or_default(), PER_ACCOUNT); - if !ok { - return Err(Refusal::new( - StatusCode::TOO_MANY_REQUESTS, - "too many submissions this hour: try again later", - )); - } - let at = Instant::now(); - recent.entry(String::new()).or_default().push_back(at); - recent - .entry(login.to_lowercase()) - .or_default() - .push_back(at); - } let dir = inner.config.scratch.join(random_hex()); std::fs::create_dir_all(&dir) .map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/crates/horizon-mod-registry/src/main.rs b/crates/horizon-mod-registry/src/main.rs index 81fe1b7b6..a1e40a57a 100644 --- a/crates/horizon-mod-registry/src/main.rs +++ b/crates/horizon-mod-registry/src/main.rs @@ -1,5 +1,5 @@ //! `horizon-mod-registry`: serves on `PORT` (default 8080). Configured by the -//! environment (see `Config::from_env`); keeps no data. +//! environment (see `Config::from_env`); keeps the ratings in `REGISTRY_DB`. use std::net::SocketAddr; use horizon_mod_registry::{App, Config, router}; @@ -16,7 +16,8 @@ async fn main() { .await .unwrap_or_else(|e| panic!("port {port}: {e}")); eprintln!("horizon-mod-registry: listening on {port}, submitting to {repo}"); - axum::serve(listener, router(App::new(config))) + let app = App::new(config).unwrap_or_else(|e| panic!("horizon-mod-registry: {e}")); + axum::serve(listener, router(app)) .with_graceful_shutdown(async { let _ = tokio::signal::ctrl_c().await; }) diff --git a/crates/horizon-mod-registry/src/ratings.rs b/crates/horizon-mod-registry/src/ratings.rs new file mode 100644 index 000000000..0cba85142 --- /dev/null +++ b/crates/horizon-mod-registry/src/ratings.rs @@ -0,0 +1,129 @@ +//! Players' ratings of published projects, 1 to 5 stars: one per GitHub +//! account and project, which its rater can change. Stored in one SQLite +//! file (a Railway volume). Only totals leave it: never who rated what, +//! except a signed-in account's own rating, to that account. +use rusqlite::{Connection, OptionalExtension, params}; +use std::{collections::BTreeMap, path::Path}; + +const SCHEMA: &str = " +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +CREATE TABLE IF NOT EXISTS ratings ( + project TEXT NOT NULL, + account INTEGER NOT NULL, + stars INTEGER NOT NULL CHECK (stars BETWEEN 1 AND 5), + updated INTEGER NOT NULL, + PRIMARY KEY (project, account) +) WITHOUT ROWID; +"; + +/// A project's ratings: their mean, and how many. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Summary { + pub average: f64, + pub count: u64, +} + +pub struct Ratings { + db: Connection, +} + +impl Ratings { + /// Opens (or creates) the file at `path`; `:memory:` for a test. + pub fn open(path: &Path) -> rusqlite::Result { + let db = Connection::open(path)?; + db.execute_batch(SCHEMA)?; + Ok(Ratings { db }) + } + + /// Sets `account`'s rating of `project`, replacing an earlier one. + pub fn rate(&self, project: &str, account: u64, stars: u8, now: u64) -> rusqlite::Result<()> { + self.db.execute( + "INSERT INTO ratings (project, account, stars, updated) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (project, account) DO UPDATE SET stars = excluded.stars, updated = excluded.updated", + params![project, account as i64, stars, now as i64], + )?; + Ok(()) + } + + /// `account`'s own rating of `project`. + pub fn mine(&self, project: &str, account: u64) -> rusqlite::Result> { + self.db + .query_row( + "SELECT stars FROM ratings WHERE project = ?1 AND account = ?2", + params![project, account as i64], + |r| r.get(0), + ) + .optional() + } + + /// `project`'s summary; None when nobody rated it. + pub fn summary(&self, project: &str) -> rusqlite::Result> { + self.db + .query_row( + "SELECT avg(stars), count(*) FROM ratings WHERE project = ?1 HAVING count(*) > 0", + [project], + |r| { + Ok(Summary { + average: r.get(0)?, + count: r.get::<_, i64>(1)? as u64, + }) + }, + ) + .optional() + } + + /// Every rated project's summary. + pub fn summaries(&self) -> rusqlite::Result> { + let mut statement = self + .db + .prepare("SELECT project, avg(stars), count(*) FROM ratings GROUP BY project")?; + let rows = statement.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + Summary { + average: r.get(1)?, + count: r.get::<_, i64>(2)? as u64, + }, + )) + })?; + rows.collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_rating_per_account_and_project() { + let r = Ratings::open(Path::new(":memory:")).unwrap(); + r.rate("zoom", 1, 5, 10).unwrap(); + r.rate("zoom", 2, 2, 10).unwrap(); + r.rate("zoom", 1, 4, 11).unwrap(); + r.rate("minimap", 1, 3, 11).unwrap(); + assert_eq!(r.mine("zoom", 1).unwrap(), Some(4)); + assert_eq!(r.mine("zoom", 3).unwrap(), None); + let all = r.summaries().unwrap(); + assert_eq!( + all["zoom"], + Summary { + average: 3.0, + count: 2 + } + ); + assert_eq!( + all["minimap"], + Summary { + average: 3.0, + count: 1 + } + ); + assert_eq!(r.summary("zoom").unwrap(), Some(all["zoom"])); + assert_eq!(r.summary("nothing").unwrap(), None); + assert!( + r.rate("zoom", 1, 6, 12).is_err(), + "the table checks the range too" + ); + } +} diff --git a/crates/horizon-mod-registry/tests/submit.rs b/crates/horizon-mod-registry/tests/submit.rs index f586effac..d5591d4fc 100644 --- a/crates/horizon-mod-registry/tests/submit.rs +++ b/crates/horizon-mod-registry/tests/submit.rs @@ -1,6 +1,6 @@ //! The whole submission against a mock GitHub: sign in, submit the sample //! signed mod (`crates/horizon-mods/tests/index`), and what lands on GitHub; -//! then what is refused. +//! then what is refused. Then ratings: who may rate what, and what is shown. use axum::{ Json, Router, body::{Body, Bytes}, @@ -55,7 +55,9 @@ async fn mock_github() -> (String, Shared) { .route( "/user", get(|State(s): State| async move { - Json(json!({"login": s.lock().unwrap().login, "id": 7})) + let login = s.lock().unwrap().login.clone(); + let id = if login == "alice" { 7 } else { 8 }; + Json(json!({"login": login, "id": id})) }), ) .route( @@ -174,7 +176,16 @@ impl Client { } async fn sign_in(&mut self) -> String { - let (status, _, location) = self.send(self.get("/api/mods/auth/login")).await; + self.sign_in_from("").await + } + + /// Signs in from the page `back` (`?back=` of the sign-in; "" for none). + async fn sign_in_from(&mut self, back: &str) -> String { + let login = match back { + "" => "/api/mods/auth/login".to_owned(), + back => format!("/api/mods/auth/login?back={back}"), + }; + let (status, _, location) = self.send(self.get(&login)).await; assert_eq!(status, StatusCode::SEE_OTHER); let location = location.unwrap(); assert!(location.contains( @@ -197,10 +208,11 @@ impl Client { .send(self.get(&format!("/api/mods/auth/callback?code=good&state={state}"))) .await; assert_eq!(status, StatusCode::SEE_OTHER); - assert_eq!( - location.as_deref(), - Some("https://playhorizon.test/mods/submit") - ); + let page = match back { + "/mods" | "/mods/zoom" => back, + _ => "/mods/submit", + }; + assert_eq!(location, Some(format!("https://playhorizon.test{page}"))); let (status, me, _) = self.send(self.get("/api/mods/me")).await; assert_eq!(status, StatusCode::OK); me["csrf"].as_str().unwrap().to_owned() @@ -265,9 +277,10 @@ async fn setup() -> (Client, Shared) { "bot".into(), ), scratch, + database: ":memory:".into(), }; let client = Client { - app: router(App::new(config)), + app: router(App::new(config).unwrap()), cookies: HashMap::new(), }; (client, mock) @@ -342,11 +355,12 @@ async fn what_the_form_refuses() { ); let (status, json) = client.submit(&csrf, &f, &file).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{json}"); - // Shader packs aren't open. + // A shader pack runs the game's own check: a mod's file isn't one. let mut f = form(); f["kind"] = json!("shader"); let (status, json) = client.submit(&csrf, &f, &file).await; - refused(status, &json, "shader packs open"); + assert_eq!(status, StatusCode::BAD_REQUEST, "{json}"); + refused(status, &json, "horizon-shaders.toml: missing"); // An update by someone who isn't an owner. let owned = "id = \"coords_hud\"\nkind = \"mod\"\nname = \"C\"\nsummary = \"s\"\nowners = [\"bob\"]\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n"; mock.lock() @@ -375,3 +389,95 @@ async fn what_the_form_refuses() { "nothing reached GitHub" ); } + +const ENTRY: &str = "kind = \"mod\"\nname = \"N\"\nsummary = \"s\"\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"1.0.0\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"f.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n"; + +impl Client { + async fn rate(&mut self, csrf: &str, id: &str, body: &str) -> (StatusCode, Value) { + let request = Request::post(format!("/api/mods/{id}/rating")) + .header(header::COOKIE, self.cookie_header()) + .header(header::ORIGIN, PUBLIC) + .header("x-csrf-token", csrf) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_owned())) + .unwrap(); + let (status, json, _) = self.send(request).await; + (status, json) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn ratings() { + let (mut client, mock) = setup().await; + { + let mut m = mock.lock().unwrap(); + m.main.insert( + "mods/zoom.toml".into(), + format!("id = \"zoom\"\nowners = [\"bob\"]\n{ENTRY}"), + ); + m.main.insert( + "mods/mine.toml".into(), + format!("id = \"mine\"\nowners = [\"Alice\"]\n{ENTRY}"), + ); + } + // Nothing rated yet; signed out, no own rating and no rating at all. + let (status, json, _) = client.send(client.get("/api/mods/ratings")).await; + assert_eq!((status, json), (StatusCode::OK, json!({"projects": {}}))); + let (status, ..) = client.send(client.get("/api/mods/zoom/rating")).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _) = client.rate("x", "zoom", r#"{"stars": 4}"#).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // A sign-in from a project's page comes back to it; anywhere else is the form. + client.sign_in_from("https://evil.test/").await; + let csrf = client.sign_in_from("/mods/zoom").await; + let (status, json) = client.rate("wrong", "zoom", r#"{"stars": 4}"#).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{json}"); + for stars in ["0", "6"] { + let (status, json) = client + .rate(&csrf, "zoom", &format!(r#"{{"stars": {stars}}}"#)) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{json}"); + } + let (status, json) = client.rate(&csrf, "zoom", r#"{"stars": 4}"#).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json, json!({"stars": 4, "average": 4.0, "count": 1})); + // Changing it replaces it. + let (status, json) = client.rate(&csrf, "zoom", r#"{"stars": 2}"#).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["count"], 1); + let (_, json, _) = client.send(client.get("/api/mods/zoom/rating")).await; + assert_eq!(json, json!({"stars": 2})); + let (_, json, _) = client.send(client.get("/api/mods/minimap/rating")).await; + assert_eq!(json, json!({"stars": null})); + + // Not one's own project, nor one that isn't published. + let (status, json) = client.rate(&csrf, "mine", r#"{"stars": 5}"#).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{json}"); + let (status, json) = client.rate(&csrf, "nothing", r#"{"stars": 5}"#).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{json}"); + + // Another account's rating counts once more; the totals never say whose. + let mut other = Client { + app: client.app.clone(), + cookies: HashMap::new(), + }; + mock.lock().unwrap().login = "carol".into(); + let carol = other.sign_in().await; + let (status, json) = other.rate(&carol, "zoom", r#"{"stars": 5}"#).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json, json!({"stars": 5, "average": 3.5, "count": 2})); + let (status, json, _) = client.send(client.get("/api/mods/ratings")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + json, + json!({"projects": {"zoom": {"average": 3.5, "count": 2}}}) + ); + + // The limit per account and hour. + let mut last = StatusCode::OK; + for _ in 0..40 { + (last, _) = client.rate(&csrf, "zoom", r#"{"stars": 3}"#).await; + } + assert_eq!(last, StatusCode::TOO_MANY_REQUESTS); +} diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index b98c9e459..07dacd446 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -18,7 +18,8 @@ interface log { write: func(level: level, message: string); } -/// Capability `game-read`: the local player and the world clock. +/// Capability `game-read`: the local player, the world clock and the block +/// the player looks at. interface game { record player { /// Feet position, in blocks. @@ -46,6 +47,24 @@ interface game { get-player: func() -> option; /// None on the title screen. get-clock: func() -> option; + /// A block along the player's view. + record hit { + /// The block hit. + block-x: s32, + block-y: s32, + block-z: s32, + /// The point on its face where the view meets it. + x: f64, + y: f64, + z: f64, + /// `minecraft:stone`, ... + block: string, + } + /// The first block along the player's view (fluids pass), up to + /// `distance` blocks (at most 1024; past the loaded chunks, across the + /// distant terrain when LOD Distance is on), as of this tick. None when + /// nothing is hit, and on the title screen. + raycast: func(distance: f32) -> option; } /// Capability `chat`: messages only the local player sees. @@ -92,6 +111,14 @@ interface storage { interface command { /// `line` without its slash; its feedback goes to the chat as if typed. run: func(line: string); + /// `lines` (each without its slash, at most 256 a call) as `run` does, + /// but with nothing in the chat: no feedback and no failure. In single + /// player and on Horizon's servers, each runs exactly as the player + /// typing it would (the same rights), in order, 64 a tick, and the + /// server logs one line a batch; one that carries a chat message (`say`, + /// `msg`) is refused. On any other server they go as chat commands, and + /// there their feedback shows: a mod running many should say so. + run-quiet: func(lines: list); } /// Capability `ui`: widgets the game draws with its own menus' widgets, for diff --git a/crates/horizon-mods/Cargo.toml b/crates/horizon-mods/Cargo.toml index a398e4d8b..8bfa58bcd 100644 --- a/crates/horizon-mods/Cargo.toml +++ b/crates/horizon-mods/Cargo.toml @@ -17,18 +17,27 @@ wasmtime = { version = "48.0.7", default-features = false, features = ["runtime" # Turns a mod's core module into a component (cargo horizon-mod build, the tests). wit-component = { version = "0.254", optional = true } wit-parser = { version = "0.254", optional = true } +# The functions a mod imports, for its API level (src/api_level.rs): the game and the registry index. +wasmparser = { version = "0.254", optional = true } # The registry index's checks (feature `index`): SHA-256, and minisign signatures (no dependencies of its own). sha2 = { version = "0.10", optional = true } minisign-verify = { version = "0.3", optional = true } +# A shader pack's check (kind `shader`): the one the game runs before it loads a pack. +horizon-shader-api = { path = "../horizon-shader-api", optional = true } + +[dev-dependencies] +# A component made for a newer Horizon, from the WIT alone (tests/sandbox.rs). +wit-component = { version = "0.254", features = ["dummy-module"] } +wit-parser = "0.254" [features] # Loads and runs mods (the game's `mods` feature); without it only manifests, # content and packaging. Off by default so builds that don't run mods (the # game's tests and probes) skip wasmtime and cranelift. -runtime = ["dep:wasmtime"] +runtime = ["dep:wasmtime", "dep:wasmparser"] pack = ["dep:wit-component", "dep:wit-parser"] # The mod registry's index (docs/api/mod-index.md): the launcher, the registry server and its CI. -index = ["dep:sha2", "dep:minisign-verify"] +index = ["dep:sha2", "dep:minisign-verify", "dep:horizon-shader-api", "dep:wasmparser"] # Runs real mods: `cargo test -p horizon-mods --features runtime,pack`. [[test]] diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs new file mode 100644 index 000000000..42b3fb4ef --- /dev/null +++ b/crates/horizon-mods/src/api_level.rs @@ -0,0 +1,194 @@ +//! Mod API levels: which of the WIT contract's functions a Horizon has +//! (`docs/api/stability.md`). Every function the game offers mods has the +//! level it arrived at; a Horizon serves every level up to its [`LEVEL`]. +//! A mod needs the level of the newest function it imports, so a mod that +//! calls a function this Horizon doesn't have is refused with "update +//! Horizon" instead of a linking error, and the registry lists each +//! version's level so the launcher can tell before it installs. + +/// The newest level this Horizon serves. +pub const LEVEL: u32 = 4; + +/// Every function the game offers mods, as `interface.function`, with its +/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new +/// function takes the next level and raises [`LEVEL`] (the `api_reference` +/// test fails until it is listed here). +pub const FUNCTIONS: [(&str, u32); 37] = [ + ("log.write", 0), + ("game.get-player", 0), + ("game.get-clock", 0), + ("chat.show", 0), + ("hud.set-panel", 0), + ("hud.clear", 0), + ("storage.get", 0), + ("storage.set", 0), + ("storage.delete", 0), + ("command.run", 0), + ("ui.open-screen", 0), + ("ui.close-screen", 0), + ("ui.set-settings", 0), + ("input.claim-items", 0), + ("input.register-key", 0), + ("input.held", 0), + ("input.poll", 0), + ("input.claim-wheel", 0), + ("input.mapping", 0), + ("input.clicks-per-second", 0), + ("render.set-hud", 0), + ("render.gui-size", 0), + ("render.shake", 0), + ("render.set-corner", 0), + ("render.clear-corner", 0), + ("render.text-width", 0), + ("render.frame-stats", 0), + ("render.set-zoom", 0), + ("render.set-minimap", 1), + ("players.online", 1), + ("players.tell", 1), + ("players.broadcast", 1), + ("game.raycast", 2), + ("command.run-quiet", 3), + ("world.get-block", 4), + ("world.get-biome", 4), + ("world.set-blocks", 4), +]; + +/// What the game's program carries so the launcher can read its level +/// without running it: `horizon-mod-api-level:0001;` (four digits). A +/// program from before the mark serves level 0. +pub const MARK: [u8; 27] = { + let mut mark = *b"horizon-mod-api-level:0000;"; + let mut level = LEVEL; + let mut i = 25; + while level > 0 { + mark[i] = b'0' + (level % 10) as u8; + level /= 10; + i -= 1; + } + mark +}; + +/// The level a program's bytes carry in their [`MARK`], if any. +pub fn read_mark(program: &[u8]) -> Option { + let prefix = &MARK[..22]; + program + .windows(MARK.len()) + .filter(|w| w.starts_with(prefix) && w[26] == b';') + .find_map(|w| std::str::from_utf8(&w[22..26]).ok()?.parse().ok()) +} + +/// A function's level; None for one this Horizon doesn't have. +pub fn level_of(function: &str) -> Option { + FUNCTIONS + .iter() + .find(|(name, _)| *name == function) + .map(|&(_, level)| level) +} + +/// The level a mod needs, from the functions it imports (as [`imports`] +/// names them): its newest function's. A function this Horizon doesn't +/// know comes from a newer one, so it needs more than [`LEVEL`]; that +/// function is returned with it, for the message. +pub fn needed<'a>(imports: impl IntoIterator) -> (u32, Option<&'a str>) { + let mut needed = (0, None); + for name in imports { + match level_of(name) { + Some(level) if level > needed.0 => needed = (level, Some(name)), + Some(_) => {} + None => return (LEVEL + 1, Some(name)), + } + } + needed +} + +/// Why this Horizon can't run a mod that needs `level`, or None when it can. +pub fn too_new(level: u32, function: Option<&str>) -> Option { + (level > LEVEL).then(|| { + let uses = function.map_or(String::new(), |f| format!(" (it uses {f})")); + format!("made for a newer Horizon{uses}: update Horizon to run it") + }) +} + +/// The functions a mod's component imports from Horizon (`horizon:mods/*`), +/// as `interface.function`, in the component's order. +#[cfg(any(feature = "runtime", feature = "index"))] +pub fn imports(wasm: &[u8]) -> Result, String> { + use wasmparser::{ + Parser, Payload, Validator, WasmFeatures, component_types::ComponentEntityType, + }; + let types = Validator::new_with_features(WasmFeatures::all()) + .validate_all(wasm) + .map_err(|e| format!("not a component: {e}"))?; + let types = types.as_ref(); + let mut out = Vec::new(); + for payload in Parser::new(0).parse_all(wasm) { + let Payload::ComponentImportSection(section) = payload.map_err(|e| e.to_string())? else { + continue; + }; + for import in section { + let name = import.map_err(|e| e.to_string())?.name.name; + // `horizon:mods/render@0.1.0` → `render`. + let Some(interface) = name.strip_prefix("horizon:mods/") else { + continue; + }; + let interface = interface.split('@').next().unwrap_or(interface); + let Some(item) = types.component_item_for_import(name) else { + continue; + }; + if let ComponentEntityType::Instance(id) = item.ty { + for (function, export) in &types[id].exports { + if matches!(export.ty, ComponentEntityType::Func(_)) { + out.push(format!("{interface}.{function}")); + } + } + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn levels_are_listed_once_and_up_to_this_horizons() { + for (i, (name, level)) in FUNCTIONS.iter().enumerate() { + assert!(*level <= LEVEL, "{name}"); + assert!( + FUNCTIONS[..i].iter().all(|(other, _)| other != name), + "{name} twice" + ); + } + assert!(FUNCTIONS.iter().any(|&(_, level)| level == LEVEL)); + } + + #[test] + fn the_mark_carries_the_level() { + assert_eq!(read_mark(&MARK), Some(LEVEL)); + let mut program = b"\0\x7fELF horizon-mod-api-level: horizon-mod-api-level:".to_vec(); + program.extend_from_slice(&MARK); + program.extend_from_slice(b"\0rest"); + assert_eq!(read_mark(&program), Some(LEVEL)); + assert_eq!(read_mark(b"horizon-mod-api-level:12;"), None); + assert_eq!(read_mark(b"no mark"), None); + } + + #[test] + fn a_mod_needs_its_newest_function() { + assert_eq!(needed(["log.write", "hud.set-panel"]), (0, None)); + assert_eq!( + needed(["log.write", "render.set-minimap", "hud.clear"]), + (1, Some("render.set-minimap")) + ); + assert_eq!( + needed(["render.set-minimap", "render.set-sky"]), + (LEVEL + 1, Some("render.set-sky")) + ); + assert_eq!(too_new(LEVEL, None), None); + assert_eq!( + too_new(LEVEL + 1, Some("render.set-sky")).as_deref(), + Some("made for a newer Horizon (it uses render.set-sky): update Horizon to run it") + ); + } +} diff --git a/crates/horizon-mods/src/index.rs b/crates/horizon-mods/src/index.rs index db860fd9f..4ef292a23 100644 --- a/crates/horizon-mods/src/index.rs +++ b/crates/horizon-mods/src/index.rs @@ -47,8 +47,8 @@ pub enum Kind { Modpack, /// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon. ResourcePack, - /// A Horizon shader pack (the native WGSL format, not Iris or OptiFine - /// packs). Listed by the schema; submissions open with its API. + /// A Horizon shader pack (`.hshader`, the native WGSL format of + /// docs/shaders.md; not the shaders/-folder packs, which stay on Modrinth). Shader, Other(String), } @@ -69,7 +69,8 @@ impl Kind { match self { Kind::Mod => Some("hmod"), Kind::Modpack => Some("hpack"), - Kind::ResourcePack | Kind::Shader => Some("zip"), + Kind::ResourcePack => Some("zip"), + Kind::Shader => Some(horizon_shader_api::EXTENSION), Kind::Other(_) => None, } } @@ -121,6 +122,14 @@ pub struct Project { pub key: Option, /// Newest first. pub versions: Vec, + /// Its files' downloads from their releases, all versions together: + /// GitHub's counts, added by the registry's publish job (hourly). + #[serde(default, skip_serializing_if = "is_zero")] + pub downloads: u64, +} + +fn is_zero(n: &u64) -> bool { + *n == 0 } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -137,6 +146,11 @@ pub struct Version { pub side: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub capabilities: Vec, + /// The mod API level a mod's code needs ([`crate::api_level`]), read + /// from its file; none for content and other kinds, and in indexes + /// built before levels. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_level: Option, /// The Minecraft version it is for. pub game: String, /// A modpack's mods, as its manifest lists them. @@ -316,6 +330,21 @@ impl Index { Ok(()) } + /// A project's newest version for Minecraft `game` that wasn't taken + /// down and that a Horizon serving mod API `level` can run (an unknown + /// level counts as runnable: the game still checks when it starts it). + pub fn installable<'a>( + &self, + project: &'a Project, + game: &str, + level: u32, + ) -> Option<&'a Version> { + project.versions.iter().find(|v| { + v.game == game + && v.api_level.is_none_or(|l| l <= level) + && self.revoked(&project.id, &v.file.sha256).is_none() + }) + } pub fn project(&self, id: &str) -> Option<&Project> { self.projects.iter().find(|p| p.id == id) } @@ -448,7 +477,7 @@ pub fn check_file( (None, None) => Trust::Unsigned, _ => return Err("its signature and the project's key don't go together".into()), }; - if matches!(project.kind, Kind::Shader | Kind::Other(_)) { + if matches!(project.kind, Kind::Other(_)) { return Err(format!( "this Horizon can't install a {} yet", project.kind.name() @@ -471,6 +500,8 @@ pub struct Facts { pub side: Option, /// In the manifest's order. pub capabilities: Vec, + /// The mod API level a mod's code needs, from the functions it imports. + pub api_level: Option, /// The Minecraft version, when the file names one (a modpack does). pub game: Option, pub requires: Vec, @@ -487,6 +518,7 @@ impl Facts { || self.api != version.api || self.side != version.side || caps != listed + || version.api_level.is_some() && self.api_level != version.api_level || self.game.as_ref().is_some_and(|g| *g != version.game) || self.requires != version.requires; if !differs { @@ -502,6 +534,13 @@ impl Facts { names(&self.capabilities) ), Kind::Modpack => format!("its {} isn't what the index lists", modpack::MANIFEST), + Kind::Shader => format!( + "its {} ({} {}, shader API {}) isn't what the index lists", + horizon_shader_api::MANIFEST, + self.id.as_deref().unwrap_or("?"), + self.version.as_deref().unwrap_or("?"), + self.api + ), _ => format!("its pack format {} isn't what the index lists", self.api), }) } @@ -509,14 +548,22 @@ impl Facts { /// Reads and checks a file as its kind: a mod's manifest and content, a /// modpack's manifest and overrides, a resource pack's `pack.mcmeta` and -/// paths. Shader packs and kinds this build doesn't know are refused: their -/// checks come with their formats. +/// paths, a shader pack's `horizon_shader_api::check` (the game's own). Kinds +/// this build doesn't know are refused: their checks come with their formats. pub fn inspect(kind: &Kind, path: &Path) -> Result { match kind { Kind::Mod => { - let m = Package::read(path)?.manifest; + let Package { manifest: m, wasm } = Package::read(path)?; crate::Content::read(path, &m)?; + let api_level = match wasm { + Some(wasm) => { + let functions = crate::api_level::imports(&wasm)?; + Some(crate::api_level::needed(functions.iter().map(String::as_str)).0) + } + None => None, + }; Ok(Facts { + api_level, capabilities: m.capabilities, id: Some(m.id), version: Some(m.version), @@ -540,9 +587,23 @@ pub fn inspect(kind: &Kind, path: &Path) -> Result { api: check_resource_pack(path)?, ..Facts::default() }), - Kind::Shader => Err( - "Horizon's own shader pack format isn't out yet: shader packs open when it is".into(), - ), + Kind::Shader => { + let pack = horizon_shader_api::Pack::read(path)?; + if let Err(problems) = horizon_shader_api::check(&pack) { + let lines: Vec = problems.iter().map(ToString::to_string).collect(); + return Err(format!( + "the shader pack fails its check:\n{}", + lines.join("\n") + )); + } + let m = pack.manifest; + Ok(Facts { + id: Some(m.id), + version: Some(m.version), + api: m.api, + ..Facts::default() + }) + } Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")), } } @@ -591,14 +652,11 @@ fn check_resource_pack(path: &Path) -> Result { Ok(format) } -/// The kinds the registry takes submissions for. Shader packs wait for -/// Horizon's own format; other kinds for theirs. +/// The kinds the registry takes submissions for; other kinds wait for their +/// formats. pub fn open(kind: &Kind) -> Result<(), String> { match kind { - Kind::Mod | Kind::Modpack | Kind::ResourcePack => Ok(()), - Kind::Shader => Err( - "shader packs open when Horizon's own shader pack format is out (Iris and OptiFine packs stay on Modrinth)".into(), - ), + Kind::Mod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()), Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")), } } diff --git a/crates/horizon-mods/src/index/registry.rs b/crates/horizon-mods/src/index/registry.rs index 21b5de9fb..05fff7f14 100644 --- a/crates/horizon-mods/src/index/registry.rs +++ b/crates/horizon-mods/src/index/registry.rs @@ -231,6 +231,7 @@ pub fn project(entry: &Entry, src: &Sources) -> Result> { icon, key: entry.key.as_ref().map(|k| k.trim().to_owned()), versions, + downloads: 0, }) } @@ -283,6 +284,7 @@ fn version(entry: &Entry, v: &EntryVersion, src: &Sources) -> Result PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/index") @@ -61,6 +62,23 @@ file = "cozy-1.0.0.hpack" sha256 = "241ac840f9c5ea3f6252d849da10c90d36cb1da4d6cd8d1d268c2760a353cd65" "#; +const GLOW: &str = r#"id = "glow" +kind = "shader" +name = "Soft Glow" +summary = "A post-processing pass over the world." +authors = ["Horizon"] +owners = ["ludovic111"] +license = "MIT" +source = "https://github.com/ludovic111/horizon-mods" + +[[versions]] +version = "1.0.0" +published = "2026-10-26" +game = "26.3" +file = "glow-1.0.0.hshader" +sha256 = "331cfe29a666a0de804e9a9ff3d19d246eebcfbfa70b4f49bba8482d30ad9f02" +"#; + const REVOKED_TOML: &str = r#"[[revoked]] id = "bad_mod" sha256 = "1111111111111111111111111111111111111111111111111111111111111111" @@ -82,9 +100,14 @@ impl Scratch { let s = Scratch(dir); s.write("mods/coords_hud.toml", &hud_entry()); s.write("mods/cozy.toml", COZY); + s.write("mods/glow.toml", GLOW); s.write("revoked.toml", REVOKED_TOML); s.write("icons/coords_hud.png", "png"); - for (name, hash) in [("coords_hud-0.1.0.hmod", HMOD), ("cozy-1.0.0.hpack", HPACK)] { + for (name, hash) in [ + ("coords_hud-0.1.0.hmod", HMOD), + ("cozy-1.0.0.hpack", HPACK), + ("glow-1.0.0.hshader", HSHADER), + ] { std::fs::copy(fixtures().join(name), s.0.join("files").join(hash)).unwrap(); } s @@ -117,7 +140,7 @@ fn builds_the_sample_index() { let s = Scratch::new("sample"); let built = s.build().unwrap(); let sample = sample(); - assert_eq!(built.projects, sample.projects[..2]); + assert_eq!(built.projects, sample.projects[..3]); assert_eq!(built.revoked, sample.revoked); assert_eq!(built.generated, sample.generated); // What it writes reads back. @@ -154,7 +177,11 @@ fn lists_every_problem_of_a_registry() { "{errors:?}" ); assert!(has("coords_hud 0.1.0: the project has a key"), "{errors:?}"); - assert!(has("glow: shader packs open when"), "{errors:?}"); + // A shader entry is taken now, and checked like the others. + assert!( + has("glow 1.0.0: the file isn't the one its sha256 names"), + "{errors:?}" + ); assert!( has("other 0.1.0: the file's id is \"coords_hud\""), "{errors:?}" @@ -219,7 +246,7 @@ fn a_review_protects_published_files() { #[test] fn only_open_kinds_are_taken() { assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok()); - assert!(open(&Kind::Shader).unwrap_err().contains("Iris")); + assert!(open(&Kind::Shader).is_ok()); assert!(open(&Kind::Other("world".into())).is_err()); let mut submit: Entry = toml::from_str(COZY).unwrap(); submit.id = "submit".into(); diff --git a/crates/horizon-mods/src/index/tests.rs b/crates/horizon-mods/src/index/tests.rs index e3883f79e..486704b7d 100644 --- a/crates/horizon-mods/src/index/tests.rs +++ b/crates/horizon-mods/src/index/tests.rs @@ -1,7 +1,8 @@ //! The sample index (`tests/index/index-v1.json`, the one docs/api/mod-index.md //! shows) and its files: `coords_hud-0.1.0.hmod`, signed with the throwaway -//! key `test.pub` by minisign 0.12 (its secret key was deleted), and -//! `cozy-1.0.0.hpack`, unsigned. +//! key `test.pub` by minisign 0.12 (its secret key was deleted), +//! `cozy-1.0.0.hpack`, unsigned, and `glow-1.0.0.hshader`, unsigned +//! (`horizon-shader new --template post`, then `horizon-shader pack`). use super::*; use std::path::PathBuf; @@ -25,16 +26,22 @@ fn reads_the_sample_and_keeps_unknown_kinds() { let index = sample(); assert_eq!(index.schema, SCHEMA); let kinds: Vec<&str> = index.projects.iter().map(|p| p.kind.name()).collect(); - assert_eq!(kinds, ["mod", "modpack", "world"]); - assert_eq!(index.projects[2].kind, Kind::Other("world".into())); + assert_eq!(kinds, ["mod", "modpack", "shader", "world"]); + assert_eq!(index.projects[3].kind, Kind::Other("world".into())); let hud = index.project("coords_hud").unwrap(); assert_eq!( hud.versions[0].capabilities, [Capability::GameRead, Capability::Hud, Capability::Chat] ); // What CI writes reads back the same. - let again = Index::parse(&serde_json::to_string(&index).unwrap()).unwrap(); + let text = serde_json::to_string(&index).unwrap(); + let again = Index::parse(&text).unwrap(); assert_eq!(again, index); + // Download counts: the publish job adds them; none is left out. + assert_eq!(hud.downloads, 0); + assert!(!text.contains("downloads")); + let counted = text.replacen("\"versions\"", "\"downloads\":12345,\"versions\"", 1); + assert_eq!(Index::parse(&counted).unwrap().projects[0].downloads, 12345); } #[test] @@ -44,6 +51,8 @@ fn files_pass_against_their_entries() { assert_eq!(check(&index, "coords_hud", &hmod), Ok(Trust::Verified)); let hpack = fixtures().join("cozy-1.0.0.hpack"); assert_eq!(check(&index, "cozy", &hpack), Ok(Trust::Unsigned)); + let hshader = fixtures().join("glow-1.0.0.hshader"); + assert_eq!(check(&index, "glow", &hshader), Ok(Trust::Unsigned)); let world = index.project("skyblock_start").unwrap(); let e = check_file(&index, world, &world.versions[0], &hpack).unwrap_err(); assert!(e.contains("bytes"), "{e}"); @@ -73,6 +82,15 @@ fn a_changed_file_key_or_manifest_is_refused() { let e = check(&more_caps, "coords_hud", &hmod).unwrap_err(); assert!(e.contains("isn't what the index lists"), "{e}"); + let mut other_level = index.clone(); + other_level.projects[0].versions[0].api_level = Some(1); + let e = check(&other_level, "coords_hud", &hmod).unwrap_err(); + assert!(e.contains("isn't what the index lists"), "{e}"); + // An index built before levels lists none: the file still passes. + let mut no_level = index.clone(); + no_level.projects[0].versions[0].api_level = None; + assert_eq!(check(&no_level, "coords_hud", &hmod), Ok(Trust::Verified)); + let mut revoked = index.clone(); revoked.revoked.push(Revoked { id: "coords_hud".into(), @@ -163,3 +181,79 @@ fn resource_packs_need_a_pack_mcmeta() { assert!(e.contains("plain path"), "{e}"); std::fs::remove_dir_all(&dir).unwrap(); } + +#[test] +fn the_newest_version_this_horizon_runs_is_installed() { + let mut index = sample(); + let mut newer = index.projects[0].versions[0].clone(); + newer.version = "0.2.0".into(); + newer.file.sha256 = "2".repeat(64); + newer.api_level = Some(crate::api_level::LEVEL + 1); + index.projects[0].versions.insert(0, newer); + let p = index.project("coords_hud").unwrap(); + let pick = |level| { + index + .installable(p, "26.3", level) + .map(|v| v.version.as_str()) + }; + assert_eq!(pick(crate::api_level::LEVEL), Some("0.1.0")); + assert_eq!(pick(crate::api_level::LEVEL + 1), Some("0.2.0")); + assert_eq!(index.installable(p, "26.4", crate::api_level::LEVEL), None); + // No level listed: runnable as far as the index knows. + index.projects[0].versions[0].api_level = None; + let p = index.project("coords_hud").unwrap(); + let v = index.installable(p, "26.3", 0).unwrap(); + assert_eq!(v.version, "0.2.0"); +} + +#[test] +fn shader_packs_pass_the_game_s_own_check() { + let manifest = "schema = 1\nid = \"glow\"\nname = \"Glow\"\nversion = \"1.0.0\"\napi = \"0.1\"\n\ + [[post]]\nfile = \"post.wgsl\"\n"; + let pack = |post: &str| { + let files = [ + (horizon_shader_api::MANIFEST, manifest), + ("post.wgsl", post), + ] + .map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec())) + .into(); + horizon_shader_api::Pack::from_files(files) + .unwrap() + .package() + .unwrap() + }; + let dir = std::env::temp_dir().join(format!("horizon-mods-shader-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let good = dir.join("glow-1.0.0.hshader"); + std::fs::write( + &good, + pack("fn post(input: PostInput) -> vec4 { return input.color; }\n"), + ) + .unwrap(); + let bad = dir.join("bad.hshader"); + std::fs::write( + &bad, + pack("fn post(input: PostInput) -> vec4 { return input.color }\n"), + ) + .unwrap(); + let facts = inspect(&Kind::Shader, &good); + let refused = inspect(&Kind::Shader, &bad).unwrap_err(); + let not_a_pack = inspect(&Kind::Shader, &fixtures().join("cozy-1.0.0.hpack")).unwrap_err(); + std::fs::remove_dir_all(&dir).unwrap(); + let facts = facts.unwrap(); + assert_eq!( + ( + facts.id.as_deref(), + facts.version.as_deref(), + facts.api.as_str() + ), + (Some("glow"), Some("1.0.0"), "0.1") + ); + assert!(refused.contains("post.wgsl:1:"), "{refused}"); + assert!( + not_a_pack.contains(horizon_shader_api::MANIFEST), + "{not_a_pack}" + ); + assert_eq!(Kind::Shader.extension(), Some("hshader")); + assert!(open(&Kind::Shader).is_ok()); +} diff --git a/crates/horizon-mods/src/lib.rs b/crates/horizon-mods/src/lib.rs index d352f8c90..983df73d4 100644 --- a/crates/horizon-mods/src/lib.rs +++ b/crates/horizon-mods/src/lib.rs @@ -5,6 +5,7 @@ //! //! 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; diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 99430bff7..0d3035e0f 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -43,7 +43,7 @@ mod ui_bindings { }); } pub use bindings::horizon::mods::{ - game::{Clock, Player}, + game::{Clock, Hit, Player}, hud::{Corner, Line}, }; use bindings::{ @@ -96,6 +96,11 @@ const FUEL_EVENT: u64 = 100_000_000; pub const BLOCKS_PER_CALL: usize = 4096; /// Chat lines a mod may show per call, HUD lines and their length. const CHAT_PER_CALL: usize = 8; +/// `command.run-quiet` lines one call of a mod may queue (a quiet payload's +/// cap, `horizon_protocol::quiet::MAX_LINES`). +const QUIET_PER_CALL: usize = 256; +/// How far `game.raycast` sees, in blocks. +pub const RAYCAST_MAX: f32 = 1024.0; const HUD_LINES: usize = 16; const LINE_CHARS: usize = 256; /// Total bytes a mod's `storage` may hold on disk, across all its keys. @@ -131,6 +136,10 @@ pub struct Snapshot { pub font_widths: Arc<[f32]>, /// The players on the server, for server mods. pub players: Vec, + /// The first block along the player's view within [`RAYCAST_MAX`] and + /// its distance from the eye, for `game.raycast`; the game works it out + /// only while a mod calls it ([`Mods::wants_raycast`]). + pub view_hit: Option<(f32, Hit)>, } /// Which mods a game starts: the window's game the client mods, the game @@ -161,6 +170,9 @@ pub enum Output { /// `command.run`: a vanilla command line, without its slash, to run as /// the player. Command { line: String }, + /// `command.run-quiet`: vanilla command lines to run as the player with + /// nothing in the chat (`horizon_protocol::quiet` on a server). + QuietCommands { mod_id: String, lines: Vec }, /// The mod was turned off: it trapped, overran or failed to start. Stopped { name: String, reason: String }, /// `players.tell`: a line after the mod's name, to the player with this id. @@ -258,6 +270,8 @@ struct Running { /// server mod. ui: Option, hooks: Hooks, + /// It imports `game.raycast`. + raycasts: bool, } /// A running mod's exports and the hooks its `init` asked for. @@ -491,6 +505,12 @@ impl Mods { .map(|(name, _)| name.to_string()) .collect(); m.check_imports(imports.iter().map(String::as_str))?; + // A function this Horizon doesn't have: say so, not wasmtime's linking error. + let functions = crate::api_level::imports(wasm)?; + let (level, function) = crate::api_level::needed(functions.iter().map(String::as_str)); + if let Some(why) = crate::api_level::too_new(level, function) { + return Err(why); + } let data_dir = path .parent() .unwrap_or(Path::new(".")) @@ -565,7 +585,12 @@ impl Mods { } self.generation += 1; self.out.append(&mut store.data_mut().out); - Ok(Some(Running { store, ui, hooks })) + Ok(Some(Running { + store, + ui, + hooks, + raycasts: functions.iter().any(|f| f == "game.raycast"), + })) } /// True when at least one mod runs: the game skips its mod work otherwise. @@ -840,6 +865,13 @@ impl Mods { .iter() .filter_map(|e| e.run.as_ref()?.store.data().corner.as_ref()) } + /// Some running mod calls `game.raycast`: the snapshot needs a + /// [`Snapshot::view_hit`]. + pub fn wants_raycast(&self) -> bool { + self.entries + .iter() + .any(|e| e.run.as_ref().is_some_and(|r| r.raycasts)) + } /// Some running mod has a `frame` hook: the game refreshes the snapshot /// every frame for it. pub fn wants_frames(&self) -> bool { @@ -1219,6 +1251,11 @@ impl game::Host for State { fn get_clock(&mut self) -> Option { self.snapshot.clock } + fn raycast(&mut self, distance: f32) -> Option { + let (at, hit) = self.snapshot.view_hit.as_ref()?; + // NaN compares false: no hit. + (*at <= distance.min(RAYCAST_MAX) && !distance.is_nan()).then(|| hit.clone()) + } } impl State { /// Queues a chat line unless the call already sent [`CHAT_PER_CALL`]. @@ -1543,6 +1580,23 @@ impl command::Host for State { self.out.push(Output::Command { line: clip(line) }); } } + fn run_quiet(&mut self, lines: Vec) { + let queued: usize = self + .out + .iter() + .map(|o| match o { + Output::QuietCommands { lines, .. } => lines.len(), + _ => 0, + }) + .sum(); + let room = QUIET_PER_CALL.saturating_sub(queued); + if room > 0 && !lines.is_empty() { + self.out.push(Output::QuietCommands { + mod_id: self.id.clone(), + lines: lines.into_iter().take(room).map(clip).collect(), + }); + } + } } impl hud::Host for State { fn set_panel(&mut self, corner: Corner, lines: Vec) { diff --git a/crates/horizon-mods/tests/api_reference.rs b/crates/horizon-mods/tests/api_reference.rs index b3d2363a9..6e9a92c85 100644 --- a/crates/horizon-mods/tests/api_reference.rs +++ b/crates/horizon-mods/tests/api_reference.rs @@ -302,3 +302,38 @@ fn every_capability_with_an_interface_is_in_the_contract() { } } } + +/// Every function the game offers (an interface some world imports and none +/// exports) has an API level, and the levels list nothing else. +#[test] +fn every_function_has_an_api_level() { + let mut resolve = Resolve::default(); + let package = resolve.push_file(root().join(WIT)).unwrap(); + let package = &resolve.packages[package]; + let exported: Vec<_> = package + .worlds + .values() + .flat_map(|&w| resolve.worlds[w].exports.values()) + .filter_map(|item| match item { + WorldItem::Interface { id, .. } => Some(*id), + _ => None, + }) + .collect(); + let mut offered = Vec::new(); + for (name, &id) in &package.interfaces { + if !exported.contains(&id) { + for function in resolve.interfaces[id].functions.keys() { + offered.push(format!("{name}.{function}")); + } + } + } + for f in &offered { + assert!( + horizon_mods::api_level::level_of(f).is_some(), + "{f} has no level in crates/horizon-mods/src/api_level.rs: add it at the next level" + ); + } + for (f, _) in horizon_mods::api_level::FUNCTIONS { + assert!(offered.iter().any(|o| o == f), "{f} isn't in the WIT"); + } +} diff --git a/crates/horizon-mods/tests/index/glow-1.0.0.hshader b/crates/horizon-mods/tests/index/glow-1.0.0.hshader new file mode 100644 index 000000000..dda1651f8 Binary files /dev/null and b/crates/horizon-mods/tests/index/glow-1.0.0.hshader differ diff --git a/crates/horizon-mods/tests/index/index-v1.json b/crates/horizon-mods/tests/index/index-v1.json index 215c94667..4f92c7c1d 100644 --- a/crates/horizon-mods/tests/index/index-v1.json +++ b/crates/horizon-mods/tests/index/index-v1.json @@ -29,6 +29,7 @@ "hud", "chat" ], + "api_level": 0, "game": "26.3", "file": { "name": "coords_hud-0.1.0.hmod", @@ -75,6 +76,34 @@ } ] }, + { + "id": "glow", + "kind": "shader", + "name": "Soft Glow", + "summary": "A post-processing pass over the world.", + "authors": [ + "Horizon" + ], + "owners": [ + "ludovic111" + ], + "license": "MIT", + "source": "https://github.com/ludovic111/horizon-mods", + "versions": [ + { + "version": "1.0.0", + "published": "2026-10-26", + "api": "0.1", + "game": "26.3", + "file": { + "name": "glow-1.0.0.hshader", + "url": "https://github.com/ludovic111/horizon-mods/releases/download/glow-1.0.0/glow-1.0.0.hshader", + "size": 689, + "sha256": "331cfe29a666a0de804e9a9ff3d19d246eebcfbfa70b4f49bba8482d30ad9f02" + } + } + ] + }, { "id": "skyblock_start", "kind": "world", diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index b6f619bc4..8fc519a76 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -2,8 +2,8 @@ //! examples and a guest //! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`. use horizon_mods::{ - BlockPos, Clock, Corner, GameMode, HeldItem, Mods, Output, Package, Player, Runs, ServerPlayer, - Shape, Snapshot, Status, + BlockPos, Clock, Corner, GameMode, HeldItem, Hit, Mods, Output, Package, Player, Runs, + ServerPlayer, Shape, Snapshot, Status, manifest::componentize, testing::{Script, TestGame}, }; @@ -26,7 +26,7 @@ id = "test_ui_guest" name = "Test UI Guest" version = "0.1.0" api = "0.1" -capabilities = ["chat", "ui", "render", "input"] +capabilities = ["game-read", "chat", "command", "ui", "render", "input"] experimental = ["ui", "render", "input"] "#; @@ -200,6 +200,65 @@ fn undeclared_capabilities_are_refused() { assert_eq!(mods.entries()[0].status, refused); } +/// A mod calling a function this Horizon doesn't have is refused with +/// "update Horizon", not a linking error; the test mods' levels are read +/// from the functions they import. +#[test] +fn a_mod_for_a_newer_horizon_is_refused() { + use horizon_mods::api_level::{imports, needed}; + use wit_parser::{ManglingAndAbi, Resolve}; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let wit = std::fs::read_to_string(root.join("crates/horizon-mod-sdk/wit/horizon.wit")) + .unwrap() + .replace( + " set-minimap: func(minimap: option);", + " set-minimap: func(minimap: option);\n set-sky: func(on: bool);", + ); + let mut resolve = Resolve::default(); + let package = resolve.push_str("horizon.wit", &wit).unwrap(); + let world = resolve + .select_world(&[package], Some("horizon-ui-mod")) + .unwrap(); + let mut module = wit_component::dummy_module(&resolve, world, ManglingAndAbi::Standard32); + wit_component::embed_component_metadata( + &mut module, + &resolve, + world, + wit_component::StringEncoding::UTF8, + ) + .unwrap(); + let wasm = wit_component::ComponentEncoder::default() + .module(&module) + .unwrap() + .encode() + .unwrap(); + let functions = imports(&wasm).unwrap(); + assert!(functions.iter().any(|f| f == "render.set-sky")); + let dir = mods_dir().join("too_new"); + std::fs::create_dir_all(&dir).unwrap(); + let manifest = r#" +id = "too_new" +name = "Too New" +version = "0.1.0" +api = "0.1" +capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input"] +experimental = ["ui", "render", "input"] +"#; + std::fs::write(dir.join("horizon-mod.toml"), manifest).unwrap(); + std::fs::write(dir.join("mod.wasm"), wasm).unwrap(); + let mods = Mods::load(&[dir], |_| true, |_| false); + let refused = "made for a newer Horizon (it uses render.set-sky): update Horizon to run it"; + assert_eq!(mods.entries()[0].status, Status::Failed(refused.into())); + + let level = |file: &str| { + let wasm = Package::read(&mods_dir().join(file)).unwrap().wasm.unwrap(); + needed(imports(&wasm).unwrap().iter().map(String::as_str)).0 + }; + assert_eq!(level("coords_hud.hmod"), 0); + // The UI guest runs quiet commands (`command.run-quiet`, level 3). + assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 3); +} + #[test] fn vanilla_commands_win() { let mods = Mods::load( @@ -666,6 +725,73 @@ fn render_minimap_is_clamped_and_hidden() { assert_eq!(map(&mut mods, "off"), None); } +/// `game.raycast`: the view's hit the game worked out, up to the distance +/// asked for; the game works it out only while a mod imports the call. +#[test] +fn game_raycast_sees_the_view_hit_within_its_distance() { + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + assert!(mods.wants_raycast()); + assert!(!load("coords_hud.hmod").wants_raycast()); + let said = |mods: &mut Mods, args: &str| -> Vec { + assert!(mods.command(&format!("raycast {args}"))); + mods.drain() + .into_iter() + .filter_map(|o| match o { + Output::Chat { text, .. } => Some(text), + _ => None, + }) + .collect() + }; + assert_eq!(said(&mut mods, "100"), ["no hit"]); + mods.set_snapshot(Snapshot { + view_hit: Some(( + 40.5, + Hit { + block_x: 12, + block_y: 63, + block_z: -44, + x: 12.5, + y: 64.0, + z: -43.75, + block: "minecraft:grass_block".into(), + }, + )), + ..player() + }); + let hit = "hit minecraft:grass_block at 12 63 -44 (12.5 64.0 -43.8)"; + assert_eq!(said(&mut mods, "100"), [hit]); + assert_eq!(said(&mut mods, "40"), ["no hit"]); + // NaN asks for nothing; past 1024 is 1024. + assert_eq!(said(&mut mods, "x"), ["no hit"]); + assert_eq!(said(&mut mods, "1e9"), [hit]); +} + +#[test] +fn quiet_commands_come_out_as_one_batch_capped() { + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + let quiet = |mods: &mut Mods, command: &str| -> Vec<(String, Vec)> { + assert!(mods.command(command)); + mods.drain() + .into_iter() + .filter_map(|o| match o { + Output::QuietCommands { mod_id, lines } => Some((mod_id, lines)), + _ => None, + }) + .collect() + }; + assert_eq!( + quiet(&mut mods, "quiet setblock 0 0 0 stone;say hi"), + [( + "test_ui_guest".to_string(), + vec!["setblock 0 0 0 stone".to_string(), "say hi".to_string()] + )] + ); + let batches = quiet(&mut mods, "quiet_many 300"); + assert_eq!(batches.len(), 1); + assert_eq!(batches[0].1.len(), 256); + assert_eq!(batches[0].1[255], "setblock 255 300 0 stone"); +} + #[test] fn corner_panels_text_width_and_readings() { let mut mods = load("horizon_mods_test_ui_guest.hmod"); diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 3d4c66076..74fb64350 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -4,9 +4,11 @@ //! `/render_hud`, `/render_clear` and `/render_shake` test `render`; //! `/input_claim`, `/input_poll`, `/input_held` and `/input_wheel` test //! `input`; `/zoom FACTOR SMOOTH TURN` tests `render.set-zoom`; -//! `/corner` and `/readings` the calls the official extras mods use. +//! `/corner` and `/readings` the calls the official extras mods use; +//! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs +//! lines with `command.run-quiet` (`/quiet_many N`: N of them). use horizon_mod_sdk::{ - Mod, Registration, chat, + Mod, Registration, chat, command, game, input::{self, Event}, render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape}, ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget}, @@ -97,6 +99,9 @@ impl Mod for Guest { "input_wheel", "zoom", "minimap", + "raycast", + "quiet", + "quiet_many", "corner", "readings", ] @@ -217,6 +222,25 @@ impl Mod for Guest { }); render::set_minimap(map); } + "quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::>()), + "quiet_many" => { + let n: usize = args.parse().unwrap_or(0); + command::run_quiet( + &(0..n) + .map(|i| format!("setblock {i} 300 0 stone")) + .collect::>(), + ); + } + "raycast" => { + let distance = args.parse().unwrap_or(f32::NAN); + chat::show(&match game::raycast(distance) { + Some(h) => format!( + "hit {} at {} {} {} ({:.1} {:.1} {:.1})", + h.block, h.block_x, h.block_y, h.block_z, h.x, h.y, h.z + ), + None => "no hit".into(), + }); + } // Clamped to (1, 10); the two after it shake nothing. "render_shake" => { render::shake(2.0, 99.0); diff --git a/crates/horizon-port-pack/src/carry.rs b/crates/horizon-port-pack/src/carry.rs index 82dbf11af..a87f92ede 100644 --- a/crates/horizon-port-pack/src/carry.rs +++ b/crates/horizon-port-pack/src/carry.rs @@ -828,9 +828,9 @@ pub fn carry( // Additions to other sets and pools, as files the game merges. let modifier = serde_json::from_slice::(&bytes).ok(); let types = &mut report.worldgen_types; - if let Some((kind, mut added)) = modifier.and_then(|v| { - crate::legacy_structure_mods::lithostitched::modifier(&v, loaded, types) - }) { + if let Some((kind, mut added)) = + modifier.and_then(|v| crate::lithostitched::modifier(&v, loaded, types)) + { crate::processors::carry( kind, &mut added, diff --git a/crates/horizon-port-pack/src/fetch.rs b/crates/horizon-port-pack/src/fetch.rs index f2803baa2..82c1ea794 100644 --- a/crates/horizon-port-pack/src/fetch.rs +++ b/crates/horizon-port-pack/src/fetch.rs @@ -143,7 +143,9 @@ pub fn fetch( if let Some((dest, url)) = crate::worldgen::loader_jar(work, &pack.loader, &pack.loader_version) .filter(|(dest, _)| !dest.exists()) { - let sha1 = ureq::get(&format!("{url}.sha1")) + let checksum = format!("{url}.sha1"); + let sha1 = crate::net::agent(&checksum) + .get(&checksum) .set("User-Agent", modrinth::AGENT) .call() .ok() @@ -201,7 +203,8 @@ pub fn url_of(file: &PackFile, cache: &Cache) -> Option { /// Downloads `url` to `dest`, kept only if its SHA-1 is `sha1`. fn download(url: &str, sha1: Option<&str>, dest: &Path) -> Result<(), String> { - let response = ureq::get(url) + let response = crate::net::agent(url) + .get(url) .set("User-Agent", modrinth::AGENT) .call() .map_err(|e| format!("{url}: {e}"))?; diff --git a/crates/horizon-port-pack/src/legacy_structure_mods/ctov.rs b/crates/horizon-port-pack/src/legacy_structure_mods/ctov.rs index af46174ae..62c25739f 100644 --- a/crates/horizon-port-pack/src/legacy_structure_mods/ctov.rs +++ b/crates/horizon-port-pack/src/legacy_structure_mods/ctov.rs @@ -157,6 +157,7 @@ mod tests { } #[test] + #[ignore = "modular compat is crate::lithostitched's now (its tests)"] fn modular_compat_runs_its_list_only_with_its_mod() { let mut data = ProcessorData::default(); let swap = json!({"processor_type": "minecraft:rule", "rules": []}); diff --git a/crates/horizon-port-pack/src/legacy_structure_mods/lithostitched.rs b/crates/horizon-port-pack/src/legacy_structure_mods/lithostitched.rs deleted file mode 100644 index 54415a76a..000000000 --- a/crates/horizon-port-pack/src/legacy_structure_mods/lithostitched.rs +++ /dev/null @@ -1,503 +0,0 @@ -//! Lithostitched: its jigsaw structure, its worldgen modifiers and its -//! processors. -use super::ctov; -use crate::processors::{ProcessorData, carry_all, processors, referenced}; -use crate::worldgen::{ENHANCED_TERRAIN, Upgrade, start_checks}; -use serde_json::{Value, json}; -use std::collections::BTreeMap; - -/// A `structure` file of Lithostitched's jigsaw type as vanilla's, with the -/// start rules it shares with the other libraries' jigsaws (`worldgen`'s -/// `start_checks`). `None` for any other file. -pub(crate) fn upgrade( - kind: &str, - v: &mut Value, - lost: &mut BTreeMap, -) -> Option { - if kind != "structure" || v["type"] != "lithostitched:jigsaw" { - return None; - } - start_checks(v, "lowest", lost, false); - if v.as_object_mut() - .and_then(|o| o.remove(ENHANCED_TERRAIN)) - .is_some() - { - *lost.entry(ENHANCED_TERRAIN.into()).or_default() += 1; - } - v["type"] = "minecraft:jigsaw".into(); - Some(true) -} - -/// A lithostitched worldgen modifier -/// (`data//lithostitched/worldgen_modifier/`) that adds to structure -/// sets or template pools, as a file the game merges into them (the key -/// `horizon:add_to`, `horizon_client::mod_content`): (kind, file). The -/// modifier's conditions are checked against `loaded`, the mods ported -/// along: a compat addition for a mod that is not would place that mod's -/// blocks, which the game does not have. Other modifier types are left out. -/// Both are counted in `report`. -pub(crate) fn modifier( - v: &Value, - loaded: &dyn Fn(&str) -> bool, - report: &mut Upgrade, -) -> Option<(&'static str, Value)> { - let t = v["type"].as_str().unwrap_or_default(); - let mut left = |why: &str| *report.modifiers_left.entry(why.to_owned()).or_default() += 1; - let (kind, targets, from, to) = match t { - "lithostitched:add_structure_set_entries" => { - ("structure_set", "structure_sets", "entries", "structures") - } - "lithostitched:add_template_pool_elements" => { - ("template_pool", "template_pools", "elements", "elements") - } - _ => { - left(t); - return None; - } - }; - if !conditions_hold(v, loaded) { - left("conditions"); - return None; - } - let targets: Option> = match &v[targets] { - Value::String(s) => Some(vec![s.as_str()]), - Value::Array(a) => a.iter().map(Value::as_str).collect(), - _ => None, - }; - let Some(targets) = targets.filter(|t| !t.is_empty() && t.iter().all(|t| !t.starts_with('#'))) - else { - left("target"); - return None; - }; - let entries = v[from].as_array().cloned().unwrap_or_default(); - for target in &targets { - *report.added_to.entry((*target).to_owned()).or_default() += entries.len(); - } - Some(( - kind, - serde_json::json!({"horizon:add_to": targets, to: entries}), - )) -} - -/// Whether a modifier's load conditions hold: lithostitched's `predicate`, -/// and the loader's conditions (NeoForge's, else Fabric's). A condition -/// type not known here does not hold. -fn conditions_hold(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool { - let all = |list: &Value, test: &dyn Fn(&Value) -> bool| { - list.as_array().is_some_and(|a| a.iter().all(test)) - }; - fn predicate(p: &Value, loaded: &dyn Fn(&str) -> bool) -> bool { - let test = |q: &Value| predicate(q, loaded); - let list = &p["predicates"]; - match p["type"] - .as_str() - .map(|t| t.trim_start_matches("lithostitched:")) - { - Some("true") => true, - Some("mod_loaded") => p["mod_id"].as_str().is_some_and(loaded), - Some("not") => !predicate(&p["predicate"], loaded), - Some("all_of") => list.as_array().is_some_and(|a| a.iter().all(test)), - Some("any_of") => list.as_array().is_some_and(|a| a.iter().any(test)), - _ => false, - } - } - fn neoforge(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool { - let test = |q: &Value| neoforge(q, loaded); - match c["type"].as_str() { - Some("neoforge:true") => true, - Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded), - Some("neoforge:not") => !neoforge(&c["value"], loaded), - Some("neoforge:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)), - Some("neoforge:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)), - _ => false, - } - } - fn fabric(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool { - let test = |q: &Value| fabric(q, loaded); - let mods = || { - c["values"] - .as_array() - .into_iter() - .flatten() - .map(Value::as_str) - }; - match c["condition"].as_str() { - Some("fabric:true") => true, - Some("fabric:all_mods_loaded") => mods().all(|m| m.is_some_and(loaded)), - Some("fabric:any_mods_loaded") => mods().any(|m| m.is_some_and(loaded)), - Some("fabric:not") => !fabric(&c["value"], loaded), - Some("fabric:and") => c["values"].as_array().is_some_and(|a| a.iter().all(test)), - Some("fabric:or") => c["values"].as_array().is_some_and(|a| a.iter().any(test)), - _ => false, - } - } - let litho = v.get("predicate").is_none_or(|p| predicate(p, loaded)); - let loader = match ( - v.get("neoforge:conditions"), - v.get("fabric:load_conditions"), - ) { - (Some(c), _) => all(c, &|c| neoforge(c, loaded)), - // One condition or a list of them. - (None, Some(c)) if c.is_array() => all(c, &|c| fabric(c, loaded)), - (None, Some(c)) => fabric(c, loaded), - (None, None) => true, - }; - litho && loader -} - -/// The data form of one of Lithostitched's processors: the processors to put -/// in its place, or `None` when it has none (or is not Lithostitched's). -pub(crate) fn processor( - kind: &str, - p: &Value, - data: &ProcessorData, - carried: &mut BTreeMap, -) -> Option> { - match kind { - "lithostitched:apply_random" => apply_random(p, data, carried).map(|p| vec![p]), - "lithostitched:condition" => condition(p, data, carried).map(|p| vec![p]), - "lithostitched:block_swap" => { - // The same map; the game's swap keeps the properties both have. - p["blocks"].as_object()?; - Some(vec![ - json!({"processor_type": "horizon:block_swap", "blocks": p["blocks"]}), - ]) - } - "lithostitched:set_block" => { - // A simple or weighted state provider, the block entity left as - // it is. - let provider = p["state_provider"]["type"].as_str()?; - let modifier = p["block_entity_modifier"]["type"].as_str(); - let plain = - modifier.is_none_or(|m| m.trim_start_matches("minecraft:") == "passthrough"); - if !plain - || !matches!( - provider, - "minecraft:simple_state_provider" | "minecraft:weighted_state_provider" - ) - { - return None; - } - let mut out = p.clone(); - out["processor_type"] = "horizon:set_block".into(); - if let Some(o) = out.as_object_mut() { - o.remove("block_entity_modifier"); - } - Some(vec![out]) - } - _ => None, - } -} - -/// The references (Lithostitched's, and CTOV's compat processors) in a -/// processor list written in; whether any was. -pub(crate) fn expand_references( - list: Vec, - data: &ProcessorData, - carried: &mut BTreeMap, -) -> (Vec, bool) { - references(list, data, carried, 0) -} - -/// A processor list inside a carried processor: an id stays (the game reads -/// that file, carried on its own); an inline one is carried here. -fn nested(list: &Value, data: &ProcessorData, carried: &mut BTreeMap) -> Value { - let mut list = list.clone(); - if let Some(ps) = processors(&mut list) { - *ps = carry_all(std::mem::take(ps), data, carried).0; - } - list -} - -/// Lithostitched's `apply_random` as `horizon:apply_random`: its weighted -/// lists, or its set of lists at weight 1. A tag is not carried. -fn apply_random( - p: &Value, - data: &ProcessorData, - carried: &mut BTreeMap, -) -> Option { - let lists = &p["processor_lists"]; - let weighted = lists.as_array().is_some_and(|a| { - a.iter() - .all(|e| e.get("weight").is_some() && e.get("data").is_some()) - }); - let entries: Vec<(Value, Value)> = if weighted { - lists - .as_array()? - .iter() - .map(|e| (e["weight"].clone(), e["data"].clone())) - .collect() - } else { - match lists { - Value::String(id) if !id.starts_with('#') => vec![(1.into(), lists.clone())], - Value::Array(a) if a.iter().any(|x| x.get("processor_type").is_some()) => { - vec![(1.into(), lists.clone())] - } - Value::Array(a) - if a.iter() - .all(|x| x.as_str().is_some_and(|s| !s.starts_with('#')) || x.is_object()) => - { - a.iter().map(|x| (1.into(), x.clone())).collect() - } - Value::Object(_) => vec![(1.into(), lists.clone())], - _ => return None, - } - }; - let lists: Vec = entries - .into_iter() - .map(|(weight, list)| json!({"weight": weight, "data": nested(&list, data, carried)})) - .collect(); - let mut out = json!({"processor_type": "horizon:apply_random", "processor_lists": lists}); - if let Some(mode) = p.get("mode") { - out["mode"] = mode.clone(); - } - Some(out) -} - -/// Lithostitched's `condition` as `horizon:condition`, when the game runs -/// every condition in it (true, all_of, any_of, not, random_chance, -/// matching_blocks with plain property values); else it stays Java. -fn condition( - p: &Value, - data: &ProcessorData, - carried: &mut BTreeMap, -) -> Option { - fn runs(c: &Value) -> bool { - if let Some(all) = c.as_array() { - return all.iter().all(runs); - } - let kind = c["type"].as_str().unwrap_or_default(); - match kind.strip_prefix("lithostitched:").unwrap_or(kind) { - "true" | "random_chance" => true, - "all_of" | "any_of" => c["conditions"] - .as_array() - .is_some_and(|a| a.iter().all(runs)), - "not" => runs(&c["condition"]), - "matching_blocks" => c.get("properties").is_none_or(|p| { - p.as_object() - .is_some_and(|o| o.values().all(Value::is_string)) - }), - _ => false, - } - } - if !runs(&p["if_true"]) { - return None; - } - // One processor or a list of them. - let mut branch = |key: &str| -> Value { - let list = match p.get(key) { - None => Vec::new(), - Some(Value::Array(a)) => a.clone(), - Some(one) => vec![one.clone()], - }; - carry_all(list, data, carried).0.into() - }; - let (then, otherwise) = (branch("then"), branch("else")); - let mut out = json!({"processor_type": "horizon:condition", "if_true": p["if_true"], - "then": then, "else": otherwise}); - if let Some(mode) = p.get("random_mode") { - out["random_mode"] = mode.clone(); - } - Some(out) -} - -/// Lithostitched's references in a list, each replaced by the processors of -/// the lists it names, in order (references in them too, to a depth of 8). -/// One naming a tag or a list the mod does not have stays, to be dropped. -/// Whether any was replaced. -fn references( - list: Vec, - data: &ProcessorData, - carried: &mut BTreeMap, - depth: usize, -) -> (Vec, bool) { - let mut out = Vec::with_capacity(list.len()); - let mut changed = false; - for p in list { - if let Some(inner) = ctov::modular_compat(&p, data, carried, depth) { - changed = true; - out.extend(references(inner, data, carried, depth + 1).0); - continue; - } - let named = (p["processor_type"] == "lithostitched:reference" && depth < 8) - .then(|| referenced(&p["processor_lists"], data)) - .flatten(); - match named { - Some(inner) => { - *carried.entry("lithostitched:reference".into()).or_default() += 1; - out.extend(references(inner, data, carried, depth + 1).0); - changed = true; - } - None => out.push(p), - } - } - (out, changed) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::legacy_structure_mods::data; - use crate::processors::carry; - - #[test] - fn modifiers_add_to_sets_and_pools() { - let mut report = Upgrade::default(); - let only_fd = |m: &str| m == "farmersdelight"; - let set = json!({"type": "lithostitched:add_structure_set_entries", - "structure_sets": "minecraft:villages", - "entries": [{"structure": "x:village", "weight": 4}]}); - let (kind, added) = modifier(&set, &only_fd, &mut report).unwrap(); - assert_eq!(kind, "structure_set"); - assert_eq!( - added, - json!({"horizon:add_to": ["minecraft:villages"], - "structures": [{"structure": "x:village", "weight": 4}]}) - ); - // A compat addition holds only when its mod is ported along. - let pool = |modid: &str| { - json!({"type": "lithostitched:add_template_pool_elements", - "neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}], - "fabric:load_conditions": {"condition": "fabric:all_mods_loaded", "values": ["other"]}, - "template_pools": ["x:houses", "y:houses"], - "elements": [{"weight": 5, "element": {"element_type": "minecraft:single_pool_element", - "location": "x:fd_farm", "projection": "rigid", "processors": "minecraft:empty"}}]}) - }; - let (kind, added) = modifier(&pool("farmersdelight"), &only_fd, &mut report).unwrap(); - assert_eq!(kind, "template_pool"); - assert_eq!(added["horizon:add_to"], json!(["x:houses", "y:houses"])); - assert_eq!(added["elements"][0]["weight"], 5); - assert!(modifier(&pool("create"), &only_fd, &mut report).is_none()); - // Fabric's conditions when there are no NeoForge ones; lithostitched's own. - let fabric = json!({"type": "lithostitched:add_structure_set_entries", - "fabric:load_conditions": [{"condition": "fabric:not", - "value": {"condition": "fabric:any_mods_loaded", "values": ["create"]}}], - "predicate": {"type": "lithostitched:all_of", "predicates": [ - {"type": "lithostitched:mod_loaded", "mod_id": "farmersdelight"}, - {"type": "lithostitched:true"}]}, - "structure_sets": "minecraft:villages", "entries": []}); - assert!(modifier(&fabric, &only_fd, &mut report).is_some()); - let tagged = json!({"type": "lithostitched:add_structure_set_entries", - "structure_sets": "#x:sets", "entries": []}); - assert!(modifier(&tagged, &only_fd, &mut report).is_none()); - let features = json!({"type": "lithostitched:add_features"}); - assert!(modifier(&features, &only_fd, &mut report).is_none()); - assert_eq!( - report.added_to, - BTreeMap::from([ - ("minecraft:villages".into(), 1), - ("x:houses".into(), 1), - ("y:houses".into(), 1), - ]) - ); - assert_eq!( - report.modifiers_left, - BTreeMap::from([ - ("conditions".into(), 1), - ("lithostitched:add_features".into(), 1), - ("target".into(), 1), - ]) - ); - } - - #[test] - fn lithostitched_block_swaps_become_the_games() { - let mut list = json!({"processors": [ - {"processor_type": "lithostitched:block_swap", - "blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}}, - {"processor_type": "lithostitched:block_swap", "blocks": []}]}); - let mut carried = BTreeMap::new(); - assert!(carry("processor_list", &mut list, &data(), &mut carried)); - let p = list["processors"].as_array().unwrap(); - assert_eq!( - p[0], - json!({"processor_type": "horizon:block_swap", - "blocks": {"minecraft:cobblestone": "minecraft:mossy_cobblestone"}}) - ); - // A map that is not one stays Java (dropped later). - assert_eq!(p[1]["processor_type"], "lithostitched:block_swap"); - } - - #[test] - fn lithostitched_references_are_written_in() { - let mut data = data(); - let gravity = json!({"processor_type": "minecraft:gravity", "heightmap": "WORLD_SURFACE_WG", "offset": 0}); - data.lists - .insert("v:road".into(), json!({"processors": [gravity.clone()]})); - data.lists.insert( - "v:house".into(), - json!({"processors": [ - {"processor_type": "lithostitched:reference", "processor_lists": "v:road"}, - {"processor_type": "lithostitched:block_swap", "blocks": {"minecraft:dirt": "minecraft:grass_block"}}]}), - ); - let mut list = json!({"processors": [ - {"processor_type": "lithostitched:reference", "processor_lists": ["v:house", "v:road"]}, - {"processor_type": "lithostitched:reference", "processor_lists": "#v:roads"}, - {"processor_type": "lithostitched:reference", "processor_lists": ["v:nowhere"]}]}); - let mut carried = BTreeMap::new(); - assert!(carry("processor_list", &mut list, &data, &mut carried)); - let p = list["processors"].as_array().unwrap(); - // House (its own reference to road first, then its swap), then road; - // the swap inside is carried too. - assert_eq!(p[0], gravity); - assert_eq!(p[1]["processor_type"], "horizon:block_swap"); - assert_eq!(p[2], gravity); - // A tag or a list the mod lacks stays, to be dropped. - assert_eq!(p[3]["processor_lists"], "#v:roads"); - assert_eq!(p[4]["processor_lists"], json!(["v:nowhere"])); - assert_eq!(carried["lithostitched:reference"], 2); - } - - #[test] - fn lithostitched_random_and_condition_become_the_games() { - let mut data = data(); - let swap = json!({"processor_type": "lithostitched:block_swap", - "blocks": {"minecraft:dirt": "minecraft:grass_block"}}); - data.lists - .insert("v:green".into(), json!({"processors": [swap.clone()]})); - let mut list = json!({"processors": [ - {"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [ - {"weight": 3, "data": "minecraft:empty"}, - {"weight": 1, "data": {"processors": [ - {"processor_type": "lithostitched:reference", "processor_lists": "v:green"}, - {"processor_type": "v:java"}]}}]}, - {"processor_type": "lithostitched:apply_random", "mode": "per_block", - "processor_lists": ["v:green", "v:red"]}, - {"processor_type": "lithostitched:apply_random", "mode": "per_block", - "processor_lists": "#v:colours"}, - {"processor_type": "lithostitched:condition", - "if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}], - "then": swap, "else": {"processor_type": "lithostitched:set_block", "state_provider": - {"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}}, - {"processor_type": "lithostitched:condition", - "if_true": {"type": "lithostitched:matching_biomes", "biomes": "#c:is_cold"}, - "then": []}]}); - let mut carried = BTreeMap::new(); - assert!(carry("processor_list", &mut list, &data, &mut carried)); - let p = list["processors"].as_array().unwrap(); - let green = json!({"processor_type": "horizon:block_swap", - "blocks": {"minecraft:dirt": "minecraft:grass_block"}}); - // Inline lists are carried; Java left in them is dropped later. - assert_eq!( - p[0], - json!({"processor_type": "horizon:apply_random", "mode": "per_piece", "processor_lists": [ - {"weight": 3, "data": "minecraft:empty"}, - {"weight": 1, "data": {"processors": [green.clone(), {"processor_type": "v:java"}]}}]}) - ); - assert_eq!( - p[1]["processor_lists"], - json!([{"weight": 1, "data": "v:green"}, {"weight": 1, "data": "v:red"}]) - ); - assert_eq!(p[2]["processor_type"], "lithostitched:apply_random"); - assert_eq!( - p[3], - json!({"processor_type": "horizon:condition", - "if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}], - "then": [green], "else": [{"processor_type": "horizon:set_block", "state_provider": - {"type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:stone"}}}]}) - ); - assert_eq!(p[4]["processor_type"], "lithostitched:condition"); - assert_eq!(carried["lithostitched:apply_random"], 2); - assert_eq!(carried["lithostitched:condition"], 1); - } -} diff --git a/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs b/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs index 1faa1b39f..980798720 100644 --- a/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs +++ b/crates/horizon-port-pack/src/legacy_structure_mods/mod.rs @@ -13,10 +13,11 @@ //! The general code reaches them only through the `pub(crate)` entry points //! of these three modules. Nothing in them is used by another mod's //! conversion. +#[allow(dead_code)] // its modular compat is `crate::lithostitched`'s now; the rest goes with C1 pub(crate) mod ctov; -pub(crate) mod lithostitched; #[cfg(test)] +#[allow(dead_code)] // the old Lithostitched tests used it fn data() -> crate::processors::ProcessorData { let mut d = crate::processors::ProcessorData::default(); d.spawners.insert( diff --git a/crates/horizon-port-pack/src/lib.rs b/crates/horizon-port-pack/src/lib.rs index 3b17f1c9c..3b77aec63 100644 --- a/crates/horizon-port-pack/src/lib.rs +++ b/crates/horizon-port-pack/src/lib.rs @@ -14,7 +14,9 @@ pub mod import; pub mod inventory; pub mod jar; pub(crate) mod legacy_structure_mods; +mod lithostitched; pub mod modrinth; +pub mod net; pub mod oracle; pub mod pipeline; pub mod processors; diff --git a/crates/horizon-port-pack/src/lithostitched.rs b/crates/horizon-port-pack/src/lithostitched.rs new file mode 100644 index 000000000..e65efcf0e --- /dev/null +++ b/crates/horizon-port-pack/src/lithostitched.rs @@ -0,0 +1,434 @@ +//! Lithostitched's worldgen, carried as the game reads it (clean room: +//! `cleanroom/structures/spec-lithostitched.md`; CTOV's processor: +//! `spec-ctov.md` §4). +//! +//! - `lithostitched:jigsaw` structures become vanilla's jigsaw. Lithostitched +//! lays jigsaws out its own way (OPEN-L5): Horizon keeps vanilla's layouts. +//! - Its processors (`block_swap`, `reference`, `apply_random`, `condition`, +//! `set_block`, `discard_input`) are the game's own types +//! (`horizon_world`'s `structure::processor`): they are carried as written, +//! the processors nested in them carried in turn. CTOV's +//! `ctov:modular_compat` (a reference that runs only when its mod is loaded) +//! becomes a `reference` when the mod is ported along, else nothing. +//! - `add_template_pool_elements` modifiers become `horizon:add_to` pool files +//! (`docs/mods.md`), when their load conditions pass. Lithostitched's own +//! pool elements (`limited`, `guaranteed`, `delegating`) place their +//! `delegate`; what they add to it is counted as lost. +use crate::{processors::ProcessorData, worldgen::Upgrade}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +const JIGSAW: &str = "lithostitched:jigsaw"; +/// Vanilla's JigsawStructure.MAX_DEPTH. +const MAX_SIZE: i64 = 20; +const MODULAR_COMPAT: &str = "ctov:modular_compat"; +/// The processor types the game reads. +const PROCESSORS: [&str; 6] = [ + "lithostitched:block_swap", + "lithostitched:reference", + "lithostitched:apply_random", + "lithostitched:condition", + "lithostitched:set_block", + "lithostitched:discard_input", +]; + +fn count(map: &mut BTreeMap, name: &str) { + *map.entry(name.to_owned()).or_default() += 1; +} + +/// A `lithostitched:jigsaw` structure as vanilla's jigsaw: `Some(true)`, else +/// `None`. +pub(crate) fn upgrade( + kind: &str, + v: &mut Value, + lost: &mut BTreeMap, +) -> Option { + if kind != "structure" || v["type"] != JIGSAW { + return None; + } + v["type"] = "minecraft:jigsaw".into(); + // `size` may be an int provider (sampled per start): its largest value. + let size = match &v["size"] { + Value::Null => None, + Value::Number(n) => n.as_i64(), + provider => { + count(lost, "lithostitched:jigsaw size provider"); + ["max_inclusive", "value"] + .iter() + .find_map(|k| provider[k].as_i64()) + } + }; + if let Some(size) = size { + if size > MAX_SIZE { + count(lost, "lithostitched:jigsaw size over 20"); + } + v["size"] = size.clamp(0, MAX_SIZE).into(); + } + let o = v.as_object_mut()?; + match o.remove("start_projection") { + Some(Value::String(heightmap)) if heightmap != "floor" && heightmap != "ceiling" => { + o.entry("project_start_to_heightmap") + .or_insert(heightmap.into()); + } + Some(_) => count(lost, "lithostitched:jigsaw start_projection"), + None => {} + } + if o.remove("fixed_rotation").is_some_and(|f| f == true) { + count(lost, "lithostitched:jigsaw fixed_rotation"); + } + Some(true) +} + +/// Lithostitched's pool elements as the element they wrap; whether `element` +/// was one. +pub(crate) fn upgrade_pool_element( + element: &mut Value, + lost: &mut BTreeMap, +) -> bool { + let Some(kind) = element["element_type"].as_str() else { + return false; + }; + match kind { + "lithostitched:limited" | "lithostitched:guaranteed" | "lithostitched:delegating" => { + count(lost, kind); + let delegate = element["delegate"].take(); + *element = delegate; + // A delegate wrapping another (only `delegating` allows it). + upgrade_pool_element(element, lost); + true + } + _ => false, + } +} + +/// The ids a modifier targets: an id or a list of ids (`None` for tags and +/// Lithostitched's object forms). +fn ids(v: &Value) -> Option> { + let id = |v: &Value| { + v.as_str() + .filter(|s| !s.starts_with('#')) + .map(str::to_owned) + }; + match v { + Value::Array(a) => a.iter().map(id).collect(), + other => Some(vec![id(other)?]), + } +} + +/// NeoForge's load conditions (`neoforge:conditions`): all must pass. +fn conditions_pass(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool { + fn pass(c: &Value, loaded: &dyn Fn(&str) -> bool) -> bool { + let all = |key: &str| { + c[key] + .as_array() + .into_iter() + .flatten() + .all(|c| pass(c, loaded)) + }; + match c["type"].as_str() { + Some("neoforge:mod_loaded") => c["modid"].as_str().is_some_and(loaded), + Some("neoforge:true") => true, + Some("neoforge:not") => !pass(&c["value"], loaded), + Some("neoforge:and") => all("values"), + Some("neoforge:or") => { + (c["values"].as_array().into_iter().flatten()).any(|c| pass(c, loaded)) + } + _ => false, + } + } + (v["neoforge:conditions"].as_array().into_iter().flatten()).all(|c| pass(c, loaded)) +} + +/// A worldgen modifier as a file the game merges: `("template_pool", file)` +/// for `add_template_pool_elements`; the others are counted in +/// `report.modifiers_left`. +pub(crate) fn modifier( + v: &Value, + loaded: &dyn Fn(&str) -> bool, + report: &mut Upgrade, +) -> Option<(&'static str, Value)> { + let kind = v["type"].as_str().unwrap_or("no type"); + if !conditions_pass(v, loaded) { + count(&mut report.modifiers_left, "conditions"); + return None; + } + if kind != "lithostitched:add_template_pool_elements" { + count(&mut report.modifiers_left, kind); + return None; + } + let (Some(targets), Some(elements)) = (ids(&v["template_pools"]), v["elements"].as_array()) + else { + count(&mut report.modifiers_left, "target"); + return None; + }; + for target in &targets { + *report.added_to.entry(target.clone()).or_default() += elements.len(); + } + Some(( + "template_pool", + json!({"horizon:add_to": targets, "elements": elements}), + )) +} + +/// Whether the game reads a Lithostitched processor as it is: one of its +/// types, with no processor list named by tag (the game has no processor +/// list tags) and a state provider it runs. +pub(crate) fn game_reads(p: &Value) -> bool { + let Some(kind) = p["processor_type"] + .as_str() + .filter(|t| PROCESSORS.contains(t)) + else { + return false; + }; + let tag = |v: &Value| v.as_str().is_some_and(|s| s.starts_with('#')); + match kind { + "lithostitched:reference" => match &p["processor_lists"] { + Value::Array(a) => !a.iter().any(tag), + other => other.is_string() && !tag(other), + }, + "lithostitched:apply_random" => p["processor_lists"] + .as_array() + .is_some_and(|a| a.iter().all(|e| !tag(e) && !tag(&e["data"]))), + "lithostitched:set_block" => matches!( + p["state_provider"]["type"].as_str(), + Some("minecraft:simple_state_provider" | "minecraft:weighted_state_provider") + ), + "lithostitched:block_swap" => p["blocks"].is_object(), + "lithostitched:condition" => !p["if_true"].is_null(), + _ => true, + } +} + +/// The processor lists inside a Lithostitched processor, for the general +/// code that walks lists (`then`, `else`, `apply_random`'s inline data). +pub(crate) fn inner_lists(p: &mut Value) -> Vec<&mut Value> { + match p["processor_type"].as_str() { + Some("lithostitched:apply_random") => (p["processor_lists"].as_array_mut().into_iter()) + .flatten() + .filter_map(|e| e.get_mut("data").filter(|d| !d.is_string())) + .collect(), + Some("lithostitched:condition") => (p.as_object_mut().into_iter()) + .flat_map(|o| o.iter_mut()) + .filter(|(k, _)| *k == "then" || *k == "else") + .map(|(_, v)| v) + .collect(), + _ => Vec::new(), + } +} + +/// A Lithostitched processor with the processors nested in it carried +/// (`Some`, when one of them changed); `None` for any other processor. +pub(crate) fn processor( + kind: &str, + p: &Value, + data: &ProcessorData, + carried: &mut BTreeMap, +) -> Option> { + if !PROCESSORS.contains(&kind) { + return None; + } + let mut out = p.clone(); + let mut changed = false; + for list in inner_lists(&mut out) { + // One processor alone is a list of one. + if list.get("processor_type").is_some() { + *list = Value::Array(vec![list.take()]); + changed = true; + } + if let Some(processors) = crate::processors::processors(list) { + let (inner, inner_changed) = + crate::processors::carry_all(std::mem::take(processors), data, carried); + *processors = inner; + changed |= inner_changed; + } + } + changed.then(|| vec![out]) +} + +/// The list with CTOV's modular compat processors written as the game's +/// `reference` (its mod is ported along) or taken out; whether any was. +pub(crate) fn expand_references( + list: Vec, + data: &ProcessorData, + carried: &mut BTreeMap, +) -> (Vec, bool) { + let mut changed = false; + let mut out = Vec::with_capacity(list.len()); + for p in list { + if p["processor_type"] != MODULAR_COMPAT { + out.push(p); + continue; + } + changed = true; + count(carried, MODULAR_COMPAT); + let loaded = p["modid"] + .as_str() + .is_some_and(|m| data.compat_loaded.contains(m)); + if loaded { + out.push(json!({"processor_type": "lithostitched:reference", + "processor_lists": p["reference"].clone()})); + } + } + (out, changed) +} + +/// Notes which mods CTOV's modular compat processors name are `loaded`. +pub(crate) fn mark_loaded(data: &mut ProcessorData, loaded: &dyn Fn(&str) -> bool) { + fn visit(v: &Value, out: &mut Vec) { + match v { + Value::Object(o) => { + if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT) + && let Some(m) = o.get("modid").and_then(Value::as_str) + { + out.push(m.to_owned()); + } + o.values().for_each(|v| visit(v, out)); + } + Value::Array(a) => a.iter().for_each(|v| visit(v, out)), + _ => {} + } + } + let mut named = Vec::new(); + data.lists.values().for_each(|l| visit(l, &mut named)); + data.compat_loaded + .extend(named.into_iter().filter(|m| loaded(m))); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worldgen::upgrade as upgrade_file; + + #[test] + fn jigsaws_become_vanillas() { + let mut lost = BTreeMap::new(); + let mut s = json!({"type": JIGSAW, "size": {"type": "uniform", "min_inclusive": 3, + "max_inclusive": 40}, "start_projection": "WORLD_SURFACE_WG", "fixed_rotation": true}); + assert_eq!(upgrade("structure", &mut s, &mut lost), Some(true)); + assert_eq!( + s, + json!({"type": "minecraft:jigsaw", "size": 20, + "project_start_to_heightmap": "WORLD_SURFACE_WG"}) + ); + assert_eq!(lost.len(), 3); + assert_eq!( + upgrade( + "structure", + &mut json!({"type": "minecraft:jigsaw"}), + &mut lost + ), + None + ); + } + + #[test] + fn wrapped_elements_place_their_delegate() { + let mut lost = BTreeMap::new(); + let delegate = json!({"element_type": "minecraft:single_pool_element", "location": "a:b", + "projection": "rigid", "processors": "minecraft:empty"}); + let mut e = + json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate}); + assert!(upgrade_pool_element(&mut e, &mut lost)); + assert_eq!(e, delegate); + assert_eq!(lost["lithostitched:limited"], 1); + assert!(!upgrade_pool_element(&mut e, &mut lost)); + } + + #[test] + fn modifiers_add_to_pools_when_their_mods_are_loaded() { + let mut report = Upgrade::default(); + let loaded = |m: &str| m == "waystones"; + let add = |modid: &str, pools: Value| { + json!({"type": "lithostitched:add_template_pool_elements", "template_pools": pools, + "elements": [{"weight": 2, "element": {"element_type": "minecraft:empty_pool_element"}}], + "neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": modid}]}) + }; + let (kind, file) = modifier( + &add("waystones", json!("ctov:village/plains/house")), + &loaded, + &mut report, + ) + .unwrap(); + assert_eq!(kind, "template_pool"); + assert_eq!(file["horizon:add_to"], json!(["ctov:village/plains/house"])); + assert_eq!(file["elements"][0]["weight"], 2); + assert_eq!(report.added_to["ctov:village/plains/house"], 1); + assert!(modifier(&add("sawmill", json!("a:b")), &loaded, &mut report).is_none()); + assert!(modifier(&add("waystones", json!("#a:pools")), &loaded, &mut report).is_none()); + let aliases = json!({"type": "lithostitched:set_pool_aliases"}); + assert!(modifier(&aliases, &loaded, &mut report).is_none()); + assert_eq!( + report.modifiers_left, + BTreeMap::from([ + ("conditions".into(), 1), + ("lithostitched:set_pool_aliases".into(), 1), + ("target".into(), 1), + ]) + ); + } + + /// A CTOV list (spec-ctov §4): references, a condition with a single + /// processor and a nested condition, a random list holding the compat + /// hook, and processors the game cannot read. + #[test] + fn processors_stay_lithostitcheds_with_their_insides_carried() { + let hook = json!({"processor_type": MODULAR_COMPAT, "modid": "natures_spirit", + "reference": "ctov:village/mesa/house_wisteria"}); + let list = json!({"processors": [ + {"processor_type": "lithostitched:reference", "processor_lists": ["ctov:village/mesa/house"]}, + {"processor_type": "lithostitched:condition", "random_mode": "per_block", + "if_true": {"type": "lithostitched:matching_blocks", "blocks": "minecraft:water", + "match_type": "location"}, + "then": {"processor_type": "lithostitched:set_block", "state_provider": { + "type": "minecraft:simple_state_provider", "state": {"Name": "minecraft:spruce_planks"}}}, + "else": [{"processor_type": "somemod:java_processor"}]}, + {"processor_type": "lithostitched:apply_random", "mode": "per_piece", "processor_lists": [ + {"weight": 1, "data": "minecraft:empty"}, + {"weight": 1, "data": {"processors": [hook]}}]}, + {"processor_type": "lithostitched:reference", "processor_lists": "#ctov:palettes"}, + {"processor_type": "lithostitched:set_block", "state_provider": { + "type": "minecraft:noise_provider"}}]}); + for (loaded, wisteria) in [ + (false, json!([])), + ( + true, + json!([{"processor_type": + "lithostitched:reference", "processor_lists": "ctov:village/mesa/house_wisteria"}]), + ), + ] { + let mut data = ProcessorData::default(); + data.lists + .insert("ctov:village/mesa/house".into(), list.clone()); + mark_loaded(&mut data, &|m| loaded && m == "natures_spirit"); + let mut v = list.clone(); + let mut report = Upgrade::default(); + crate::processors::carry( + "processor_list", + &mut v, + &data, + &mut report.processors_carried, + ); + upgrade_file("processor_list", &mut v, &mut report, &BTreeMap::new()); + let p = &v["processors"]; + assert_eq!(p.as_array().unwrap().len(), 3, "{p}"); + assert_eq!(p[0], list["processors"][0]); + // One processor is a list of one; the Java one is taken out. + assert_eq!( + p[1]["then"][0]["state_provider"]["state"]["id"], + "minecraft:spruce_planks" + ); + assert_eq!(p[1]["else"], json!([])); + assert_eq!(p[2]["processor_lists"][0]["data"], "minecraft:empty"); + assert_eq!(p[2]["processor_lists"][1]["data"]["processors"], wisteria); + assert_eq!(report.processors_carried[MODULAR_COMPAT], 1); + assert_eq!( + report.processors_dropped, + BTreeMap::from([ + ("lithostitched:reference".into(), 1), + ("lithostitched:set_block".into(), 1), + ("somemod:java_processor".into(), 1), + ]) + ); + } + } +} diff --git a/crates/horizon-port-pack/src/modrinth.rs b/crates/horizon-port-pack/src/modrinth.rs index 4e90c871a..17d6b3222 100644 --- a/crates/horizon-port-pack/src/modrinth.rs +++ b/crates/horizon-port-pack/src/modrinth.rs @@ -79,7 +79,8 @@ impl Cache { } fn post(url: &str, body: &Value) -> Result { - let response = ureq::post(url) + let response = crate::net::agent(url) + .post(url) .set("User-Agent", AGENT) .set("Content-Type", "application/json") .send_string(&body.to_string()) @@ -88,7 +89,8 @@ fn post(url: &str, body: &Value) -> Result { } fn get(url: &str) -> Result { - let response = ureq::get(url) + let response = crate::net::agent(url) + .get(url) .set("User-Agent", AGENT) .call() .map_err(|e| format!("{url}: {e}"))?; diff --git a/crates/horizon-port-pack/src/net.rs b/crates/horizon-port-pack/src/net.rs new file mode 100644 index 000000000..a441fb13a --- /dev/null +++ b/crates/horizon-port-pack/src/net.rs @@ -0,0 +1,389 @@ +//! The agents port-pack's downloads go through: the jars of an imported modpack come from the same +//! places as everything else, so they take the same proxy — and leave the same hosts alone. +//! +//! The launcher hands its own setting over (`set_proxy`, from `proxy.rs`); a run of the +//! `horizon-port-pack` command reads `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` instead (`env_for`), +//! taking the one that matches the address's scheme the way the launcher does. Either way a host in +//! `NO_PROXY`, or on this computer, is reached directly: a host that only answers without the proxy +//! would otherwise fail here while the launcher's own downloads worked. +//! +//! Which hosts those are is decided here and nowhere else. The launcher asks `direct`, `env_for`, +//! `host_of`, `scheme_of`, `socks` and `parse` for its own traffic, and keeps its agents here too +//! (`keep`), so the two agree whatever the player set — and there is one copy of the rules to +//! change when they need it. +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +/// What the launcher set, when it did. +static PROXY: Mutex> = Mutex::new(None); + +/// Agents kept for reuse, named by the part that asked and the proxy they use: a fresh `Agent` +/// starts a new connection pool, so building one per request costs a handshake per file. +static KEPT: Mutex>> = Mutex::new(None); + +/// The proxy to use from now on: the launcher's setting, or None to fall back to the environment. +/// The agents built so far are dropped when it changes, since they hold the address it replaces. +pub fn set_proxy(spec: Option) { + let spec = spec.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + let mut proxy = lock(&PROXY); + if *proxy == spec { + return; + } + *proxy = spec; + drop(proxy); + *lock(&KEPT) = None; +} + +/// The proxy address `url` is fetched through: what the launcher set, else the environment's for +/// its scheme, unless `url` is a host that must be reached directly. +fn spec_for(url: &str) -> Option { + if direct(url) { + return None; + } + lock(&PROXY) + .clone() + .or_else(|| env_for(scheme_of(url).unwrap_or("https"))) +} + +/// An agent for downloading `url`: through the proxy when one is set, unless `url` is a host that +/// must be reached directly. +pub fn agent(url: &str) -> ureq::Agent { + keep("port-pack", spec_for(url).as_deref(), |proxy| { + let builder = ureq::AgentBuilder::new(); + match proxy { + Some(proxy) => builder.proxy(proxy).build(), + None => builder.build(), + } + }) +} + +/// The agent `url` is fetched with, built by `build` and then kept for the next request that names +/// the same `slot` and proxy: a fresh `Agent` starts a new connection pool, so building one per +/// request is a handshake per file, and a modpack's jars are fetched one after another. +pub fn keep( + slot: &str, + spec: Option<&str>, + build: impl FnOnce(Option) -> ureq::Agent, +) -> ureq::Agent { + let proxy = spec.and_then(parse); + // An address that isn't a proxy is used as none, and remembered as none. + let key = format!( + "{slot} {}", + if proxy.is_some() { + spec.unwrap_or_default() + } else { + "" + } + ); + if let Some(agent) = lock(&KEPT).as_ref().and_then(|kept| kept.get(&key)) { + return agent.clone(); + } + let agent = build(proxy); + lock(&KEPT) + .get_or_insert_with(HashMap::new) + .insert(key, agent.clone()); + agent +} + +/// `spec` as a proxy, when it is one. An address that can't be used is said once: a download left +/// to go direct would otherwise fail with nothing pointing at the proxy. +pub fn parse(spec: &str) -> Option { + if socks(spec) { + say(&format!( + "{} is a SOCKS proxy; downloads go without one. Write the http:// address your \ + proxy app listens on as well.", + redacted(spec) + )); + return None; + } + match ureq::Proxy::new(spec) { + Ok(proxy) => Some(proxy), + Err(e) => { + say(&format!( + "The proxy address {} can't be used ({e}); downloads go without one.", + redacted(spec) + )); + None + } + } +} + +/// `spec` as it may be shown: a user name and password in it become `***`. An address that can't +/// be used is printed to stderr and shown in the launcher, and a proxy's credentials don't belong in +/// either. Everything up to the last `@` is the credentials, so a password with an unescaped `/` or +/// `@` is hidden too. +pub fn redacted(spec: &str) -> String { + let (scheme, rest) = match spec.split_once("://") { + Some((scheme, rest)) => (format!("{scheme}://"), rest), + None => (String::new(), spec), + }; + match rest.rfind('@') { + Some(at) => format!("{scheme}***@{}", &rest[at + 1..]), + None => spec.to_owned(), + } +} + +/// Whether `spec` names a SOCKS proxy, which these downloads don't go through: `ureq` reads a +/// SOCKS address happily and only fails when it connects through it. +pub fn socks(spec: &str) -> bool { + matches!( + spec.split_once("://") + .map(|(s, _)| s.to_ascii_lowercase()) + .as_deref(), + Some("socks" | "socks4" | "socks4a" | "socks5" | "socks5h") + ) +} + +/// Whether `url` is reached without the proxy: a local address, or a host `NO_PROXY` names. The +/// launcher asks this of its own requests, so both reach the same hosts directly. +pub fn direct(url: &str) -> bool { + host_of(url).is_some_and(|host| local(host) || excluded(host, &no_proxy())) +} + +/// `HTTPS_PROXY` (`HTTP_PROXY` for an http address), then `ALL_PROXY`, in the upper-case spelling +/// first and the lower-case one after, as the tools that write them do. +pub fn env_for(scheme: &str) -> Option { + let names: &[&str] = if scheme.eq_ignore_ascii_case("http") { + &["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"] + } else { + &["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"] + }; + names.iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + }) +} + +/// `NO_PROXY`'s list: the hosts reached directly whatever the proxy is. +fn no_proxy() -> String { + ["NO_PROXY", "no_proxy"] + .iter() + .find_map(|name| std::env::var(name).ok()) + .unwrap_or_default() +} + +/// The host part of a URL (`https://user@host:port/path` is `host`). +pub fn host_of(url: &str) -> Option<&str> { + let rest = url.split_once("://").map_or(url, |(_, rest)| rest); + let rest = rest.split(['/', '?', '#']).next()?; + let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host); + Some(match rest.rfind(':') { + // A port follows the last colon; an IPv6 literal keeps the colons inside its brackets. + Some(at) if !rest[at..].contains(']') => &rest[..at], + _ => rest, + }) +} + +/// `https` in `https://…`, or None for anything that isn't an absolute address. +pub fn scheme_of(url: &str) -> Option<&str> { + url.split_once("://").map(|(scheme, _)| scheme) +} + +/// Whether `host` is this computer: the game's own API and a server on the same machine. +fn local(host: &str) -> bool { + let host = bare(host); + host == "localhost" + || host.ends_with(".localhost") + || host == "::1" + || host == "0.0.0.0" + || host.starts_with("127.") +} + +/// Whether `host` is in `NO_PROXY`: `*`, `host`, `.domain`, `*.domain` and `host:port` entries. +fn excluded(host: &str, no_proxy: &str) -> bool { + let host = bare(host); + no_proxy + .split(',') + .map(str::trim) + .filter(|e| !e.is_empty()) + .any(|entry| { + if entry == "*" { + return true; + } + let entry = entry.trim_start_matches('*').trim_start_matches('.'); + let entry = match entry.rfind(':') { + Some(at) if !entry[at..].contains(']') => &entry[..at], + _ => entry, + }; + let entry = bare(entry); + !entry.is_empty() && (host == entry || host.ends_with(&format!(".{entry}"))) + }) +} + +/// A host without the brackets an IPv6 literal wears, lower case. +fn bare(host: &str) -> String { + host.trim().trim_matches(['[', ']']).to_ascii_lowercase() +} + +/// Said once, however many downloads follow: a wrong address is one thing to report, not one +/// report per file. +fn say(what: &str) { + static SAID: AtomicBool = AtomicBool::new(false); + if !SAID.swap(true, Ordering::Relaxed) { + eprintln!("{what}"); + } +} + +/// The lock, even after a thread panicked while holding it: an address lost to a panic is worse +/// than an old one kept. +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What the tests below share, since the address and the agents are one of each for the whole + /// process: they take it in turn. + fn alone() -> MutexGuard<'static, ()> { + static TURN: Mutex<()> = Mutex::new(()); + lock(&TURN) + } + + #[test] + fn credentials_are_not_shown() { + assert_eq!(redacted("http://127.0.0.1:7890"), "http://127.0.0.1:7890"); + assert_eq!(redacted("proxy.local:3128"), "proxy.local:3128"); + assert_eq!( + redacted("http://user:pass@proxy.local:3128"), + "http://***@proxy.local:3128" + ); + assert_eq!( + redacted("socks5://u:p@10.0.0.1:1080"), + "socks5://***@10.0.0.1:1080" + ); + assert_eq!( + redacted("user:pass@proxy.local:3128"), + "***@proxy.local:3128" + ); + assert_eq!(redacted("http://u:p/a@ss@proxy:1"), "http://***@proxy:1"); + assert_eq!(redacted("http://user:pass@"), "http://***@"); + } + + #[test] + fn a_proxy_the_launcher_sets_is_trimmed_and_kept() { + let _turn = alone(); + set_proxy(Some(" http://127.0.0.1:7890 ".into())); + assert_eq!( + PROXY.lock().unwrap().as_deref(), + Some("http://127.0.0.1:7890") + ); + set_proxy(Some(" ".into())); + assert!(PROXY.lock().unwrap().is_none()); + } + + /// An address on this computer is reached directly whatever `NO_PROXY` says: what an import + /// needs when the launcher's own requests to it go direct. + #[test] + fn a_local_address_is_reached_directly() { + assert!(direct("http://127.0.0.1:8130/api/v1/status")); + assert!(direct("http://localhost/api/v1/status")); + assert!(direct("https://[::1]:25565/")); + } + + #[test] + fn a_host_is_read_out_of_the_address() { + assert_eq!( + host_of("https://cdn.modrinth.com/v2/x.jar"), + Some("cdn.modrinth.com") + ); + assert_eq!( + host_of("https://user:pass@piston-meta.mojang.com:8443/a"), + Some("piston-meta.mojang.com") + ); + assert_eq!( + host_of("http://127.0.0.1:8130/api/v1/status"), + Some("127.0.0.1") + ); + assert_eq!(host_of("https://[::1]:25565/"), Some("[::1]")); + assert_eq!(host_of("https://example.com?x=1"), Some("example.com")); + assert_eq!(host_of("https://example.com#top"), Some("example.com")); + } + + #[test] + fn only_absolute_addresses_have_a_scheme() { + assert_eq!(scheme_of("https://a.b"), Some("https")); + assert_eq!(scheme_of("http://a.b"), Some("http")); + assert_eq!(scheme_of("a.b/c"), None); + } + + #[test] + fn this_computer_is_never_proxied() { + for host in [ + "localhost", + "LOCALHOST", + "[::1]", + "::1", + "127.0.0.1", + "127.1.2.3", + ] { + assert!(local(host), "{host}"); + } + for host in ["playhorizon.dev", "api.minecraftservices.com", "10.0.0.1"] { + assert!(!local(host), "{host}"); + } + } + + /// What an import with `NO_PROXY=modrinth.com` needs: the launcher's own requests to it go + /// direct, so the jars fetched for it must too. + #[test] + fn no_proxy_lists_hosts_and_their_subdomains() { + assert!(excluded("anything.at.all", "*")); + assert!(excluded("cdn.modrinth.com", "modrinth.com")); + assert!(excluded("api.modrinth.com", "modrinth.com")); + assert!(excluded("piston-meta.mojang.com", "mojang.com")); + assert!(excluded( + "wiki.corp.example", + " .corp.example , other.host " + )); + assert!(excluded("github.com", "github.com:443")); + assert!(!excluded("notmodrinth.com", "modrinth.com")); + assert!(!excluded("modrinth.com", " .corp.example , mojang.com ")); + assert!(!excluded("edge.forgecdn.net", "modrinth.com")); + assert!(!excluded("modrinth.com", "")); + } + + #[test] + fn an_address_that_cant_be_used_becomes_no_proxy() { + assert!(parse("http://127.0.0.1:7890").is_some()); + assert!(parse("127.0.0.1:7890").is_some()); + // `ureq` reads a SOCKS address and only fails once it connects through it. + assert!(socks("socks5://127.0.0.1:7891")); + assert!(parse("socks5://127.0.0.1:7891").is_none()); + assert!(!socks("http://127.0.0.1:7890")); + } + + /// The agent a second request gets is the one the first built: one connection pool, not one + /// handshake per jar. + #[test] + fn an_agent_is_kept_for_the_next_request() { + let _turn = alone(); + set_proxy(None); + let mut built = 0; + for _ in 0..3 { + keep("kept", None, |_| { + built += 1; + ureq::AgentBuilder::new().build() + }); + } + assert_eq!(built, 1, "three requests, one agent"); + // Another address is another agent, and a new setting drops the ones before it. + keep("kept", Some("http://127.0.0.1:7890"), |proxy| { + assert!(proxy.is_some()); + built += 1; + ureq::AgentBuilder::new().build() + }); + assert_eq!(built, 2, "a second address needs a second agent"); + set_proxy(Some("http://127.0.0.1:7891".into())); + keep("kept", Some("http://127.0.0.1:7891"), |_| { + built += 1; + ureq::AgentBuilder::new().build() + }); + assert_eq!(built, 3, "a new setting drops the agents kept before it"); + set_proxy(None); + } +} diff --git a/crates/horizon-port-pack/src/processors.rs b/crates/horizon-port-pack/src/processors.rs index af1595f92..046eaa0bf 100644 --- a/crates/horizon-port-pack/src/processors.rs +++ b/crates/horizon-port-pack/src/processors.rs @@ -10,13 +10,13 @@ //! - Repurposed Structures' mineshaft sky view becomes the game's //! `horizon:mineshaft_sky_view`. //! -//! The processors of YUNG's mods, Lithostitched and Choice Theorem's Overhauled -//! Village are carried by `legacy_structure_mods`, called from [`carry`]. +//! Lithostitched's processors and Choice Theorem's Overhauled Village's +//! compat hook are carried by `crate::lithostitched`, called from [`carry`]. //! //! What is not here is taken out of its list later and counted //! (`worldgen::upgrade`). The entity NBT is written in 26.3's shape (an //! `equipment` map, not 1.21.1's `HandItems` and `ArmorItems`). -use crate::legacy_structure_mods::{ctov, lithostitched}; +use crate::lithostitched; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -42,7 +42,7 @@ pub struct ProcessorData { impl ProcessorData { /// Notes which mods its compat processors name are `loaded`. pub fn mark_loaded(&mut self, loaded: &dyn Fn(&str) -> bool) { - ctov::mark_loaded(self, loaded); + lithostitched::mark_loaded(self, loaded); } pub fn read(zip: &mut zip::ZipArchive) -> Self { @@ -177,8 +177,7 @@ fn carry_at( let mut out = Vec::with_capacity(expanded.len()); for p in expanded { let kind = p["processor_type"].as_str().unwrap_or_default().to_owned(); - // Lithostitched's processors are carried apart from this code - // (`legacy_structure_mods`). + // Lithostitched's processors stay theirs, with what they hold carried. let replacement = lithostitched::processor(&kind, &p, data, carried).or_else(|| { match pillar_type(&kind) { Some(to) => Some(vec![pillar(&p, to, data, carried, depth)]), diff --git a/crates/horizon-port-pack/src/worldgen.rs b/crates/horizon-port-pack/src/worldgen.rs index 298179acf..0e3d968e3 100644 --- a/crates/horizon-port-pack/src/worldgen.rs +++ b/crates/horizon-port-pack/src/worldgen.rs @@ -15,10 +15,10 @@ //! closely. //! //! YUNG's API's files are carried as written ([`crate::yung`]). -//! Lithostitched's and CTOV's worldgen files are converted by -//! `legacy_structure_mods`, called from [`upgrade`], [`crate::carry`] and the -//! processors. -use crate::{legacy_structure_mods::lithostitched, yung}; +//! Lithostitched's jigsaws, elements, modifiers and processors are carried by +//! [`crate::lithostitched`]; the entries CTOV adds to vanilla's structure sets +//! by `legacy_structure_mods`, called from [`crate::carry`]. +use crate::{lithostitched, yung}; use serde_json::Value; use std::{ collections::{BTreeMap, BTreeSet}, @@ -191,28 +191,19 @@ fn drop_java_processors(list: &mut Value, dropped: &mut BTreeMap) let before = processors.len(); processors.retain(|p| { let t = p["processor_type"].as_str().unwrap_or_default(); - let java = !t.starts_with("minecraft:") && !t.starts_with("horizon:"); + let java = !t.starts_with("minecraft:") + && !t.starts_with("horizon:") + && !lithostitched::game_reads(p); if java { *dropped.entry(t.to_owned()).or_default() += 1; } !java }); let mut changed = processors.len() != before; - // The inline lists inside the game's apply_random and condition. + // The inline lists inside Lithostitched's apply_random and condition. for p in processors.iter_mut() { let inner: Vec<&mut Value> = match p["processor_type"].as_str() { - Some("horizon:apply_random") => p["processor_lists"] - .as_array_mut() - .into_iter() - .flatten() - .map(|e| &mut e["data"]) - .collect(), - Some("horizon:condition") => { - let o = p.as_object_mut().into_iter().flat_map(|o| o.iter_mut()); - o.filter(|(k, _)| *k == "then" || *k == "else") - .map(|(_, v)| v) - .collect() - } + Some(t) if t.starts_with("lithostitched:") => lithostitched::inner_lists(p), Some("horizon:pillar" | "horizon:bottom_pillar") => { p.get_mut("pillar_processor_list").into_iter().collect() } @@ -398,7 +389,9 @@ fn upgrade_types( } } for element in elements { - if yung::upgrade_pool_element(element, lost) { + if yung::upgrade_pool_element(element, lost) + || lithostitched::upgrade_pool_element(element, lost) + { changed = true; continue; } diff --git a/crates/horizon-prepare/src/artifacts.rs b/crates/horizon-prepare/src/artifacts.rs index 994cb1a0d..533da0cce 100644 --- a/crates/horizon-prepare/src/artifacts.rs +++ b/crates/horizon-prepare/src/artifacts.rs @@ -1,5 +1,5 @@ //! The player's own game files: the pinned version JSON, client jar and libraries from their -//! official launcher install, and the server jar they downloaded, each checked against +//! official launcher or ATLauncher install, and the server jar they downloaded, each checked against //! research-lock.json before it is copied to `.local/minecraft/` (tools/lab.py's //! `prepare`, which the Python preparer ran first), then checked again on every run //! (tools/test_rust_parity.py's `verify_artifacts`). @@ -12,9 +12,10 @@ use std::path::{Component, Path, PathBuf}; const LAUNCHER_HINT: &str = "Horizon does not download the game: play {version} once in the launcher you play \ -Minecraft Java Edition with (the official launcher, Prism Launcher, MultiMC...) so it downloads \ -the game's files, then rerun. If that launcher keeps them in another folder, pass \ ---minecraft-dir or set HORIZON_MINECRAFT_DIR."; +Minecraft Java Edition with so it downloads the game's files, then rerun. Horizon reads the \ +official launcher's folder, ATLauncher's and the CurseForge app's; for Prism Launcher, MultiMC, \ +the Modrinth App or GDLauncher, prepare from the Horizon launcher, which finds their files. If your launcher keeps \ +them in another folder, pass --minecraft-dir or set HORIZON_MINECRAFT_DIR."; const SERVER_HINT: &str = "Horizon does not download the game: download the {version} server jar yourself (minecraft.net/download/server \ while {version} is the current release, otherwise the downloads.server.url in your launcher's \ versions/{version}/{version}.json), then pass --server-jar PATH or set HORIZON_SERVER_JAR."; @@ -23,11 +24,6 @@ fn hint(text: &str, version: &str) -> String { text.replace("{version}", version) } -/// What to do when the player's launcher install lacks the pinned version's files. -pub(crate) fn launcher_hint(version: &str) -> String { - hint(LAUNCHER_HINT, version) -} - /// research-lock.json and the folder the pinned version's files go to. pub struct Lock { pub json: Value, @@ -134,6 +130,137 @@ pub fn minecraft_dir(chosen: Option<&Path>) -> PathBuf { } } +/// One launcher's copy of the pinned version: its version JSON (Mojang's file, checked by its +/// sha1), the client jar, and the folder holding its `libraries` (at the version JSON's paths) +/// and `assets`. +pub struct GameFiles { + pub folder: PathBuf, + pub manifest: PathBuf, + pub client: PathBuf, + /// Mojang's Java runtimes the launcher downloaded (java::minecraft_runtimes). + pub runtimes: PathBuf, + /// The folders looked in, each with its launcher, when none held the version. + searched: Vec<(&'static str, PathBuf)>, +} + +impl GameFiles { + /// What to do when these files are missing, with the folders looked in. + pub(crate) fn hint(&self, version: &str) -> String { + let mut text = hint(LAUNCHER_HINT, version); + if !self.searched.is_empty() { + text += &format!(" Horizon looked for {version} in:"); + for (launcher, folder) in &self.searched { + text += &format!("\n {} ({launcher})", folder.display()); + } + } + text + } +} + +/// The layouts horizon-prepare reads in `folder`: the official launcher's, then ATLauncher's, +/// which saves Mojang's version JSON as is in `configs/json/minecraft` and the client jar as +/// the library `net.minecraft:client:`. +fn layouts(folder: &Path, version: &str) -> [GameFiles; 2] { + let files = |manifest: String, client: String, runtimes: &str| GameFiles { + folder: folder.to_path_buf(), + manifest: folder.join(manifest), + client: folder.join(client), + runtimes: folder.join(runtimes), + searched: Vec::new(), + }; + let v = version; + [ + files( + format!("versions/{v}/{v}.json"), + format!("versions/{v}/{v}.jar"), + "runtime", + ), + files( + format!("configs/json/minecraft/{v}.json"), + format!("libraries/net/minecraft/client/{v}/client-{v}.jar"), + "runtimes/minecraft", + ), + ] +} + +/// The launchers whose folders are looked in when none is chosen: the official launcher's +/// (`minecraft_dir`), ATLauncher's, then the CurseForge app's `Install` folder, where it runs +/// the official launcher (its default modding folder, `curseforge/minecraft`, is in the home +/// folder on Windows and in Documents elsewhere). ATLauncher keeps its files in its working folder: +/// where its Windows installer puts it, inside the macOS app, and the folders its Linux +/// packages and Flatpak start it in. +fn launcher_folders() -> Vec<(&'static str, PathBuf)> { + let mut folders = vec![("the official launcher", minecraft_dir(None))]; + // The same folders as the Horizon launcher's (launcher/src/sources.rs `known_folders`). + let atlauncher: Vec = if cfg!(target_os = "macos") { + let app = Path::new("Applications/ATLauncher.app/Contents/Java"); + vec![ + home().join("Library/Application Support/ATLauncher"), + Path::new("/").join(app), + home().join(app), + ] + } else if cfg!(windows) { + std::env::var_os("APPDATA") + .map(|d| PathBuf::from(d).join("ATLauncher")) + .into_iter() + .collect() + } else { + let data = std::env::var_os("XDG_DATA_HOME") + .filter(|d| !d.is_empty()) + .map_or_else(|| home().join(".local/share"), PathBuf::from); + let flatpak = home().join(".var/app/com.atlauncher.ATLauncher/data"); + vec![data.join("atlauncher"), flatpak.join("atlauncher"), flatpak] + }; + folders.extend(atlauncher.into_iter().map(|dir| ("ATLauncher", dir))); + let curseforge = if cfg!(windows) { "" } else { "Documents" }; + folders.push(( + "the CurseForge app", + home().join(curseforge).join("curseforge/minecraft/Install"), + )); + folders +} + +/// The pinned version's files in the chosen folder (`--minecraft-dir`, HORIZON_MINECRAFT_DIR), +/// or the launcher above it when an instance folder (`instances/`) is chosen; with no +/// folder chosen, in the first launcher folder that has them. When none does, the official +/// layout of the first folder is returned, for the errors to name, with the folders looked in. +pub fn game_files(chosen: Option<&Path>, version: &str) -> GameFiles { + let chosen = chosen + .filter(|d| !d.as_os_str().is_empty()) + .map(Path::to_path_buf) + .or_else(|| std::env::var_os("HORIZON_MINECRAFT_DIR").map(PathBuf::from)) + .filter(|d| !d.as_os_str().is_empty()) + .map(|d| expand_user(&d)); + let folders = match chosen { + Some(dir) => { + let launcher = dir + .ancestors() + .find(|a| a.file_name().is_some_and(|n| n == "instances")) + .and_then(Path::parent) + .map(Path::to_path_buf); + let mut folders = vec![("the folder chosen", dir)]; + folders.extend(launcher.map(|dir| ("the launcher above it", dir))); + folders + } + None => launcher_folders(), + }; + // A folder with both files first: a version JSON left without its jar is used, for the + // error to name the jar, only when no folder has both. + let complete = |files: &GameFiles| files.manifest.is_file() && files.client.is_file(); + let partial = |files: &GameFiles| files.manifest.is_file(); + for found in [complete, partial] { + let mut all = folders + .iter() + .flat_map(|(_, folder)| layouts(folder, version)); + if let Some(files) = all.find(found) { + return files; + } + } + let [mut files, _] = layouts(&folders[0].1, version); + files.searched = folders; + files +} + /// Copy one file from the player's install, checked like a download; an existing verified /// copy is kept. pub(crate) fn import_copy( @@ -156,17 +283,14 @@ pub(crate) fn import_copy( .commit() } -/// The pinned version JSON from the launcher's versions/ directory. The launcher's copy wins -/// over the cached one so the manifest matches the asset index installed alongside it. -pub(crate) fn import_manifest(lock: &Lock, minecraft: &Path) -> Result { +/// The pinned version JSON from the launcher's install. The launcher's copy wins over the +/// cached one so the manifest matches the asset index installed alongside it. +pub(crate) fn import_manifest(lock: &Lock, files: &GameFiles) -> Result { let version = &lock.version; let destination = lock.directory.join("manifest.json"); - let source = minecraft - .join("versions") - .join(version) - .join(format!("{version}.json")); + let source = &files.manifest; if source.is_file() { - let manifest = read_json(&source)?; + let manifest = read_json(source)?; let downloads = manifest.get("downloads"); let sha1 = |side: &str| { downloads @@ -183,7 +307,7 @@ pub(crate) fn import_manifest(lock: &Lock, minecraft: &Path) -> Result Result, log: Log, ) -> Result<(), String> { - let minecraft = minecraft_dir(minecraft); - let version = &lock.version; - let manifest = import_manifest(lock, &minecraft)?; + let files = game_files(minecraft, &lock.version); + let minecraft = &files.folder; + let manifest = import_manifest(lock, &files)?; let client = import_copy( - &minecraft - .join("versions") - .join(version) - .join(format!("{version}.jar")), + &files.client, &lock.directory.join("jars/client.jar"), lock.pin("client_sha1")?, "sha1", - &hint(LAUNCHER_HINT, version), + &files.hint(&lock.version), )?; let downloads = manifest.get("downloads"); if downloads.and_then(|d| d.get("client_mappings")).is_some() @@ -555,7 +676,7 @@ pub fn import( for (name, artifact) in client_library_artifacts(&manifest)? { let (path, sha1) = artifact_fields(artifact)?; let relative = safe_relative(path).ok_or("Unsafe library path")?; - let source = launcher_library(&minecraft, name, &relative); + let source = launcher_library(minecraft, name, &relative); let destination = lock.directory.join("libraries/client").join(&relative); if runs_without(name) && !source.is_file() && !destination.exists() { log(&format!( @@ -569,7 +690,7 @@ pub fn import( &destination, sha1, "sha1", - &hint(LAUNCHER_HINT, version), + &files.hint(&lock.version), )?); } write_import_metadata( @@ -1122,7 +1243,7 @@ mod tests { let lock = Lock::read(&root).unwrap(); let destination = lock.directory.join("manifest.json"); - let error = import_manifest(&lock, &minecraft).unwrap_err(); + let error = import_manifest(&lock, &game_files(Some(&minecraft), "9.9")).unwrap_err(); assert!(error.contains("may have rewritten it"), "{error}"); assert!( error.contains(&format!("packages/{pinned}/9.9.json")), @@ -1136,7 +1257,7 @@ mod tests { fs::create_dir_all(&lock.directory).unwrap(); fs::copy(root.join("mojang.json"), &destination).unwrap(); - let manifest = import_manifest(&lock, &minecraft).unwrap(); + let manifest = import_manifest(&lock, &game_files(Some(&minecraft), "9.9")).unwrap(); assert!(manifest.get("patches").is_none()); assert_eq!(digest(&destination, "sha1").unwrap(), pinned); fs::remove_dir_all(&root).unwrap(); @@ -1253,6 +1374,103 @@ mod tests { assert!(prepare().unwrap_err().starts_with("Missing ")); } + /// ATLauncher keeps Mojang's version JSON in `configs/json/minecraft` and the client jar as + /// a library; its folder, or one of its instances, prepares like the official launcher's, + /// with the same pins. + #[test] + fn an_atlauncher_install_prepares() { + let fixture = Imports::new(); + let old = fixture.imported(&fixture.source); + let atlauncher = fixture.directory.join("ATLauncher"); + let put = |path: &str, data: &[u8]| { + let path = atlauncher.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, data).unwrap(); + }; + let client = fs::read(old.join("jars/client.jar")).unwrap(); + put("libraries/net/minecraft/client/9.9/client-9.9.jar", &client); + put("libraries/client.jar", b"client library"); + put("instances/Minecraft99/instance.json", b"{}"); + // A version JSON left without its jar does not hide the launcher's complete copy. + put("instances/Minecraft99/versions/9.9/9.9.json", b"{}"); + let manifest = fs::read_to_string(old.join("manifest.json")).unwrap(); + let manifest = manifest.replacen('{', r#"{"javaVersion":{"majorVersion":25},"#, 1); + put("configs/json/minecraft/9.9.json", manifest.as_bytes()); + let pinned = digest(&atlauncher.join("configs/json/minecraft/9.9.json"), "sha1").unwrap(); + let lock_path = fixture.target.join("research-lock.json"); + let text = fs::read_to_string(&lock_path).unwrap(); + let old_sha1 = digest(&old.join("manifest.json"), "sha1").unwrap(); + fs::write(&lock_path, text.replace(&old_sha1, &pinned)).unwrap(); + let lock = Lock::read(&fixture.target).unwrap(); + let server = old.join("jars/server.jar"); + let prepare = |dir: &Path| { + let _ = fs::remove_dir_all(&lock.directory); + import(&fixture.target, &lock, Some(dir), Some(&server), &|_| {}) + }; + + for chosen in [atlauncher.clone(), atlauncher.join("instances/Minecraft99")] { + prepare(&chosen).unwrap(); + let artifacts = verify(&lock).unwrap(); + assert_eq!(fs::read(&artifacts.jars["client"]).unwrap(), client); + assert_eq!( + client_libraries(&lock, &artifacts.manifest).unwrap().len(), + 1 + ); + } + let files = game_files(Some(&atlauncher), "9.9"); + assert_eq!(files.runtimes, atlauncher.join("runtimes/minecraft")); + + // The pins gate it as they gate the official layout. + put( + "libraries/net/minecraft/client/9.9/client-9.9.jar", + b"other", + ); + assert!(prepare(&atlauncher) + .unwrap_err() + .contains("is not the pinned file")); + put("libraries/net/minecraft/client/9.9/client-9.9.jar", &client); + put( + "configs/json/minecraft/9.9.json", + manifest.replace(',', ", ").as_bytes(), + ); + assert!(prepare(&atlauncher) + .unwrap_err() + .contains("may have rewritten it")); + } + + /// With no launcher's files in the chosen folder, the error names where Horizon looked and + /// the launchers it reads. + #[test] + fn the_error_names_the_folders_looked_in() { + let fixture = Imports::new(); + let empty = fixture.directory.join("instances/empty"); + fs::create_dir_all(&empty).unwrap(); + let lock = Lock::read(&fixture.target).unwrap(); + let error = import(&fixture.target, &lock, Some(&empty), None, &|_| {}).unwrap_err(); + assert!(error.starts_with(&format!( + "Missing {}", + empty.join("versions/9.9/9.9.json").display() + ))); + assert!(error.contains("Horizon looked for 9.9 in:"), "{error}"); + assert!(error.contains(&format!("\n {} (the folder chosen)", empty.display()))); + assert!(error.contains(&format!( + "\n {} (the launcher above it)", + fixture.directory.display() + ))); + assert!(error.contains("ATLauncher")); + } + + #[test] + fn every_launcher_horizon_reads_is_looked_in() { + let launchers: Vec<_> = launcher_folders() + .into_iter() + .map(|(name, _)| name) + .collect(); + for name in ["the official launcher", "ATLauncher", "the CurseForge app"] { + assert!(launchers.contains(&name), "{name}"); + } + } + #[test] fn runs_without_only_netty_native_transports() { assert!(runs_without( diff --git a/crates/horizon-prepare/src/lib.rs b/crates/horizon-prepare/src/lib.rs index 5d71e8448..830bc4118 100644 --- a/crates/horizon-prepare/src/lib.rs +++ b/crates/horizon-prepare/src/lib.rs @@ -434,7 +434,7 @@ fn prepare_impl(options: &Options, log: Log, compile_only: bool) -> Result = options.java_home.iter().cloned().collect(); homes.extend(java::minecraft_runtimes( - &artifacts::minecraft_dir(options.minecraft_dir.as_deref()).join("runtime"), + &artifacts::game_files(options.minecraft_dir.as_deref(), &lock.version).runtimes, )); let jdk = java::find_runtime(lock.java_major, &homes)?; let reused = if options.import { diff --git a/crates/horizon-prepare/src/sounds.rs b/crates/horizon-prepare/src/sounds.rs index fd171ec12..5c34d9504 100644 --- a/crates/horizon-prepare/src/sounds.rs +++ b/crates/horizon-prepare/src/sounds.rs @@ -1,5 +1,5 @@ //! The pinned version's sounds, title panorama, Unifont glyphs and built-in resource packs -//! from the player's own official launcher install, each object checked against the asset index's SHA-1, copied into +//! from the player's own launcher install, each object checked against the asset index's SHA-1, copied into //! `.local/horizon-client//assets` where the game reads them (tools/fetch_sounds.py and //! the tools/lab.py functions it called). Nothing is downloaded. @@ -18,11 +18,11 @@ use std::path::{Path, PathBuf}; fn launcher_assets( lock: &Lock, manifest: &Value, - minecraft: &Path, + files: &artifacts::GameFiles, cached_index: &Path, log: Log, ) -> Result<(PathBuf, PathBuf, Vec), String> { - let assets = minecraft.join("assets"); + let assets = files.folder.join("assets"); let info = manifest .get("assetIndex") .ok_or("The version JSON has no assetIndex")?; @@ -44,7 +44,7 @@ fn launcher_assets( return Err(format!( "Missing {}. {}", index.display(), - artifacts::launcher_hint(version) + files.hint(version) )); } let bytes = fs::read(&index).map_err(|e| format!("{}: {e}", index.display()))?; @@ -240,13 +240,13 @@ pub(crate) struct Assets { impl Assets { pub(crate) fn load(root: &Path, minecraft: Option<&Path>, log: Log) -> Result { let lock = Lock::read(root)?; - let minecraft = artifacts::minecraft_dir(minecraft); - let manifest = artifacts::import_manifest(&lock, &minecraft)?; + let files = artifacts::game_files(minecraft, &lock.version); + let manifest = artifacts::import_manifest(&lock, &files)?; let target = root.join(".local/horizon-client").join(&lock.version); let (store, index, bytes) = launcher_assets( &lock, &manifest, - &minecraft, + &files, &target.join("asset-index.json"), log, )?; @@ -677,7 +677,11 @@ mod tests { for missing in ["store", "install", "last-object"] { let f = Fixture::new(&format!("optional-panorama-{missing}")); // The JARs and manifest were imported earlier; the launcher need not remain. - artifacts::import_manifest(&Lock::read(&f.root).unwrap(), &f.minecraft).unwrap(); + artifacts::import_manifest( + &Lock::read(&f.root).unwrap(), + &artifacts::game_files(Some(&f.minecraft), "9.9"), + ) + .unwrap(); let target = f.root.join(".local/horizon-client/9.9/assets"); for name in PANORAMA { let path = target.join(name); diff --git a/crates/horizon-protocol/src/item.rs b/crates/horizon-protocol/src/item.rs index abc530278..3d5bacfe9 100644 --- a/crates/horizon-protocol/src/item.rs +++ b/crates/horizon-protocol/src/item.rs @@ -35,6 +35,7 @@ pub mod component { pub const MAP_ID: i32 = 48; pub const MAP_POST_PROCESSING: i32 = 50; pub const CHARGED_PROJECTILES: i32 = 51; + pub const BUNDLE_CONTENTS: i32 = 52; pub const POTION_CONTENTS: i32 = 53; pub const WRITABLE_BOOK_CONTENT: i32 = 56; pub const WRITTEN_BOOK_CONTENT: i32 = 57; @@ -163,7 +164,7 @@ pub struct Profile { pub properties: Vec<(String, String, Option)>, } impl Component { - fn read(kind: i32, r: &mut Reader) -> Result { + pub(crate) fn read(kind: i32, r: &mut Reader) -> Result { use component::*; Ok(match kind { MAX_STACK_SIZE | MAX_DAMAGE | DAMAGE | RARITY | REPAIR_COST | MAP_ID @@ -192,7 +193,7 @@ impl Component { resolved: r.bool()?, }) } - CHARGED_PROJECTILES => { + CHARGED_PROJECTILES | BUNDLE_CONTENTS => { let n = r.count(1024)?; Self::Stacks((0..n).map(|_| Template::read(r)).collect::>()?) } @@ -337,7 +338,7 @@ impl Component { } }) } - fn write(&self, kind: i32, w: &mut Writer) { + pub(crate) fn write(&self, kind: i32, w: &mut Writer) { match self { Self::Raw(bytes) => w.bytes(bytes), Self::Trim { material, pattern } => { @@ -784,6 +785,21 @@ mod tests { assert_eq!(w.data, bytes); } + #[test] + fn bundle_contents_are_item_stack_templates() { + // A bundle (id 7) holding 32 of item 9: BundleContents is a list of + // ItemStackTemplate, as ChargedProjectiles is. + let bytes = [1, 7, 1, 0, 52, 1, 9, 32, 0, 0]; + let stack = Slot::read(&mut Reader::new(&bytes)).unwrap().unwrap(); + assert_eq!( + stack.component(component::BUNDLE_CONTENTS), + Some(&Component::Stacks(vec![Stack::new(9, 32)])) + ); + let mut w = Writer::new(); + Slot::write(&Some(stack), &mut w); + assert_eq!(w.data, bytes); + } + #[test] fn filtered_book_pages_are_read_and_dropped() { // A writable book with one page "hi" and its filtered "**". diff --git a/crates/horizon-protocol/src/lib.rs b/crates/horizon-protocol/src/lib.rs index f0bea2ca2..299688560 100644 --- a/crates/horizon-protocol/src/lib.rs +++ b/crates/horizon-protocol/src/lib.rs @@ -11,6 +11,7 @@ pub mod frame; pub mod ids; pub mod item; pub mod packets; +pub mod quiet; pub mod registry; mod resolver; pub mod rsa; diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 1bdf43b08..26bc09a84 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -8,7 +8,7 @@ use crate::{ }, chunk::{ChunkData, LightData}, entity_data::{EntityData, Particle, Value}, - item::{Hashed, HashedSlot, Slot, Stack}, + item::{Component, Hashed, HashedSlot, Slot, Stack}, packet, packet_enum, packets::{ Empty, @@ -88,11 +88,31 @@ packet!(ContainerSetContent { carried: Option => Slot, }); /// ItemCost.STREAM_CODEC: item, count, then the exact component predicate -/// (Horizon sends and reads it as a component patch without removals). +/// (a list of typed component values, with no patch removal count). #[derive(Clone, Debug, PartialEq)] pub struct NetCost { pub item: i32, pub count: i32, + pub components: Vec<(i32, Component)>, +} +impl From for NetCost { + fn from(stack: Stack) -> Self { + Self { + item: stack.item, + count: stack.count, + components: stack.added, + } + } +} +impl From for Stack { + fn from(cost: NetCost) -> Self { + Self { + item: cost.item, + count: cost.count, + added: cost.components, + removed: Vec::new(), + } + } } /// MerchantOffer.STREAM_CODEC. #[derive(Clone, Debug, PartialEq)] @@ -114,10 +134,17 @@ impl Codec> for Offers { fn cost(r: &mut Reader) -> Result { let item = r.varint()?; let count = r.varint()?; - if r.varint()? != 0 { - return Err(Error::new("item cost components are not supported")); + let n = r.count(r.remaining())?; + let mut components = Vec::with_capacity(n.min(64)); + for _ in 0..n { + let kind = r.varint()?; + components.push((kind, Component::read(kind, r)?)); } - Ok(NetCost { item, count }) + Ok(NetCost { + item, + count, + components, + }) } let n = r.count(r.remaining())?; let mut out = Vec::with_capacity(n.min(64)); @@ -144,7 +171,11 @@ impl Codec> for Offers { fn cost(c: &NetCost, w: &mut Writer) { w.varint(c.item); w.varint(c.count); - w.varint(0); + w.varint(c.components.len() as i32); + for (kind, value) in &c.components { + w.varint(*kind); + value.write(*kind, w); + } } w.varint(v.len() as i32); for o in v { @@ -2300,6 +2331,39 @@ mod tests { use super::*; use crate::{chunk::BlockEntityInfo, packets::round_trip, text::literal}; + #[test] + fn merchant_cost_predicate_is_a_typed_list_without_patch_counts() { + // ItemCost.STREAM_CODEC: item=7, count=1, one typed component, + // potion_contents=53, potion present (water=0), no color/effects/name. + // Result: count=1, item=8, empty component patch. Secondary cost: + // item=9, count=2, empty predicate, followed by the offer fields. + let mut bytes = vec![1, 7, 1, 1, 53, 1, 0, 0, 0, 0, 1, 8, 0, 0, 1, 9, 2, 0, 0]; + bytes.extend_from_slice(&[0; 4]); // uses + bytes.extend_from_slice(&2i32.to_be_bytes()); // max uses + bytes.extend_from_slice(&[0; 16]); // xp, special price, multiplier, demand + let mut r = Reader::new(&bytes); + let offers = Offers::read(&mut r).unwrap(); + assert_eq!(r.remaining(), 0); + assert_eq!( + offers[0].cost_a.components, + vec![( + 53, + Component::Potion { + potion: Some(0), + color: None, + rest: Vec::new() + } + )] + ); + assert!(offers[0].cost_b.as_ref().unwrap().components.is_empty()); + let mut w = Writer::new(); + Offers::write(&offers, &mut w); + assert_eq!(w.data, bytes); + for len in 0..bytes.len() { + assert!(Offers::read(&mut Reader::new(&bytes[..len])).is_err()); + } + } + #[test] fn serverbound_abilities_carry_only_flying() { // ServerboundPlayerAbilitiesPacket.write: one byte, 2 when flying. @@ -2576,9 +2640,9 @@ mod tests { Clientbound::MerchantOffers(MerchantOffers { container: 3, offers: vec![NetOffer { - cost_a: NetCost { item: 5, count: 24 }, + cost_a: Stack::new(5, 24).into(), result: Stack::new(7, 1), - cost_b: Some(NetCost { item: 9, count: 1 }), + cost_b: Some(Stack::new(9, 1).into()), out_of_stock: false, uses: 2, max_uses: 16, diff --git a/crates/horizon-protocol/src/quiet.rs b/crates/horizon-protocol/src/quiet.rs new file mode 100644 index 000000000..dcf48dbee --- /dev/null +++ b/crates/horizon-protocol/src/quiet.rs @@ -0,0 +1,166 @@ +//! Quiet commands: a client mod's batch of commands that a Horizon server runs +//! as the player who sent them, at that player's own permission level, with +//! no feedback in the chat (docs/mods.md, `command.run-quiet`). +//! +//! The server announces it understands them with an empty [`CHANNEL`] payload +//! in the configuration phase (a vanilla client ignores it); a client that +//! hears it sends each batch as a serverbound [`CHANNEL`] payload, and falls +//! back to chat commands on any other server. A payload is the mod's id, then +//! the lines without their slash. +use crate::{ + Error, Result, + buf::{Reader, Writer}, +}; + +/// The custom payload channel, both ways. +pub const CHANNEL: &str = "horizon:quiet_commands"; +/// Lines in one payload at most; a payload with more is refused whole. +pub const MAX_LINES: usize = 256; +/// Characters in a line at most (a chat command's limit). +pub const MAX_LINE: usize = 256; +/// Characters in a mod id at most. +pub const MAX_MOD_ID: usize = 64; +/// Bytes of a serverbound custom payload's data at most +/// (ServerboundCustomPayloadPacket.MAX_PAYLOAD_SIZE). +pub const MAX_PAYLOAD: usize = 32767; + +/// A batch as the server reads it. +#[derive(Clone, Debug, PartialEq)] +pub struct Batch { + pub mod_id: String, + pub lines: Vec, +} + +/// `lines` as few payloads as fit [`MAX_PAYLOAD`] and [`MAX_LINES`], in +/// order. Lines longer than [`MAX_LINE`] are cut, as a chat command's are. +pub fn encode(mod_id: &str, lines: &[String]) -> Vec> { + let mod_id: String = mod_id.chars().take(MAX_MOD_ID).collect(); + let mut payloads = Vec::new(); + let mut rest = lines + .iter() + .map(|l| -> String { l.chars().take(MAX_LINE).collect() }); + let mut next = rest.next(); + while next.is_some() { + let mut batch = Vec::new(); + // The id, the count (a varint of at most 2 bytes for 256). + let mut size = string_size(&mod_id) + 2; + while let Some(line) = next.take() { + let more = string_size(&line); + if !batch.is_empty() && (batch.len() == MAX_LINES || size + more > MAX_PAYLOAD) { + next = Some(line); + break; + } + size += more; + batch.push(line); + next = rest.next(); + } + let mut w = Writer::new(); + w.string(&mod_id); + w.varint(batch.len() as i32); + for line in &batch { + w.string(line); + } + payloads.push(w.data); + } + payloads +} + +fn string_size(s: &str) -> usize { + crate::buf::varint_size(s.len() as i32) + s.len() +} + +/// A payload's batch; an error for one that breaks a cap. +pub fn decode(data: &[u8]) -> Result { + if data.len() > MAX_PAYLOAD { + return Err(Error::new("quiet commands: payload too big")); + } + let mut r = Reader::new(data); + let mod_id = r.string(MAX_MOD_ID)?; + let count = r.varint()?; + if !(0..=MAX_LINES as i32).contains(&count) { + return Err(Error::new(format!("quiet commands: {count} lines"))); + } + let lines = (0..count) + .map(|_| r.string(MAX_LINE)) + .collect::>>()?; + if r.remaining() > 0 { + return Err(Error::new("quiet commands: trailing bytes")); + } + Ok(Batch { mod_id, lines }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_batch_round_trips() { + let lines = vec!["fill 0 0 0 1 1 1 stone".to_string(), "say hi".to_string()]; + let payloads = encode("orbital", &lines); + assert_eq!(payloads.len(), 1); + assert_eq!( + decode(&payloads[0]).unwrap(), + Batch { + mod_id: "orbital".into(), + lines + } + ); + } + + #[test] + fn big_batches_split_by_lines_and_bytes() { + let lines: Vec = (0..600) + .map(|i| format!("setblock {i} 0 0 stone")) + .collect(); + let payloads = encode("m", &lines); + let decoded: Vec = payloads.iter().map(|p| decode(p).unwrap()).collect(); + assert_eq!( + decoded.iter().map(|b| b.lines.len()).collect::>(), + [256, 256, 88] + ); + assert_eq!( + decoded + .into_iter() + .flat_map(|b| b.lines) + .collect::>(), + lines + ); + let long: Vec = (0..256).map(|_| "x".repeat(300)).collect(); + for p in encode("m", &long) { + assert!(p.len() <= MAX_PAYLOAD); + assert!( + decode(&p) + .unwrap() + .lines + .iter() + .all(|l| l.len() == MAX_LINE) + ); + } + } + + #[test] + fn payloads_over_the_caps_are_refused() { + let mut w = Writer::new(); + w.string("m"); + w.varint(MAX_LINES as i32 + 1); + for _ in 0..=MAX_LINES { + w.string("say"); + } + assert!(decode(&w.data).is_err()); + let mut w = Writer::new(); + w.string("m"); + w.varint(1); + w.string(&"x".repeat(MAX_LINE + 1)); + assert!(decode(&w.data).is_err()); + let mut w = Writer::new(); + w.string("m"); + w.varint(-1); + assert!(decode(&w.data).is_err()); + assert!(decode(&[0xFF]).is_err()); + let mut w = Writer::new(); + w.string("m"); + w.varint(0); + w.u8(1); + assert!(decode(&w.data).is_err()); + } +} diff --git a/crates/horizon-server/panel/panel.css b/crates/horizon-server/panel/panel.css index 7946b5f8f..994f5eac6 100644 --- a/crates/horizon-server/panel/panel.css +++ b/crates/horizon-server/panel/panel.css @@ -1,4 +1,4 @@ -/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (website/src/tokens.css). */ +/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (crates/horizon-theme/tokens.css). */ :root { --page: #fafafc; --plate-soft: #f3f3f8; diff --git a/crates/horizon-shader-api/src/check.rs b/crates/horizon-shader-api/src/check.rs index 6c43718f7..c3ecdd158 100644 --- a/crates/horizon-shader-api/src/check.rs +++ b/crates/horizon-shader-api/src/check.rs @@ -60,6 +60,10 @@ pub struct CheckedStage { pub label: String, pub module: naga::Module, pub info: ModuleInfo, + /// The stage's WGSL without the entry points (the prelude, the shared + /// files, the file), for a renderer that calls the stage function from + /// its own shader (the sky). + pub source: String, pub expressions: usize, pub texture_reads: usize, } @@ -169,6 +173,7 @@ pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result, } -/// Players' groups of at least `min_installs` installs, as `website/scripts/benchmarks.mjs` makes them. +/// Players' groups of at least `min_installs` installs, as the website's `scripts/benchmarks.mjs` makes them. pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec { let mut by: BTreeMap<(String, String, i64, &str), Group> = BTreeMap::new(); for u in uploads { diff --git a/website/scripts/fixtures/benchmarks/README.md b/crates/horizon-telemetry-server/tests/fixtures/benchmarks/README.md similarity index 72% rename from website/scripts/fixtures/benchmarks/README.md rename to crates/horizon-telemetry-server/tests/fixtures/benchmarks/README.md index 562646a6f..51ebb9aac 100644 --- a/website/scripts/fixtures/benchmarks/README.md +++ b/crates/horizon-telemetry-server/tests/fixtures/benchmarks/README.md @@ -1,7 +1,8 @@ # Benchmark summary fixtures -Shared by the website's aggregator test (`website/scripts/benchmarks.test.mjs`) and the telemetry server's -`GET /v1/benchmarks/summary` test: the same `benchmark` items in, the same groups out. +Used by the telemetry server's `GET /v1/benchmarks/summary` test (`tests/summary.rs`) and, as a copy, by the +website's aggregator test (`scripts/benchmarks.test.mjs` in ludovic111/horizon-site, which checks its copy against +this one on main): the same `benchmark` items in, the same groups out. Change both together. - `items.jsonl`: one stored item per line (`install_id`, `day`, `version`, `data`: a benchmark report). Group RTX 4080 + i7-13700KF at render distance 12 has three installs (one with two runs); the same install at 32, diff --git a/website/scripts/fixtures/benchmarks/expected.json b/crates/horizon-telemetry-server/tests/fixtures/benchmarks/expected.json similarity index 100% rename from website/scripts/fixtures/benchmarks/expected.json rename to crates/horizon-telemetry-server/tests/fixtures/benchmarks/expected.json diff --git a/website/scripts/fixtures/benchmarks/items.jsonl b/crates/horizon-telemetry-server/tests/fixtures/benchmarks/items.jsonl similarity index 100% rename from website/scripts/fixtures/benchmarks/items.jsonl rename to crates/horizon-telemetry-server/tests/fixtures/benchmarks/items.jsonl diff --git a/crates/horizon-telemetry-server/tests/summary.rs b/crates/horizon-telemetry-server/tests/summary.rs index 3232b4f87..88ae2b283 100644 --- a/crates/horizon-telemetry-server/tests/summary.rs +++ b/crates/horizon-telemetry-server/tests/summary.rs @@ -1,5 +1,5 @@ -//! `GET /v1/benchmarks/summary` against the website's fixtures (website/scripts/fixtures/benchmarks/): the same -//! uploads must give the groups `website/scripts/benchmarks.mjs` gives. +//! `GET /v1/benchmarks/summary` against the shared fixtures (tests/fixtures/benchmarks/): the same uploads must +//! give the groups the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site) gives on its copy of them. use std::sync::Arc; use std::sync::atomic::AtomicU32; @@ -15,10 +15,7 @@ use http_body_util::BodyExt; use serde_json::Value; use tower::ServiceExt; -const FIXTURES: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../website/scripts/fixtures/benchmarks" -); +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/benchmarks"); fn items() -> Vec { std::fs::read_to_string(format!("{FIXTURES}/items.jsonl")) diff --git a/crates/horizon-theme/icons/pixel/star.svg b/crates/horizon-theme/icons/pixel/star.svg new file mode 100644 index 000000000..a27348730 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/src/lib.rs b/crates/horizon-theme/src/lib.rs index dac5bf8d2..b41536e5e 100644 --- a/crates/horizon-theme/src/lib.rs +++ b/crates/horizon-theme/src/lib.rs @@ -1,6 +1,6 @@ //! Horizon's design tokens, the one source of truth for the launcher (`launcher/src/theme.rs`), -//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`website/src/tokens.css`, -//! generated from [`css`] by `cargo run -p xtask -- tokens`). Change a value here and all three +//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`tokens.css` in this crate, +//! generated from [`css`] by `cargo run -p xtask -- tokens`, which the site's build reads from main). Change a value here and all three //! follow. The system is described in `docs/design-system.md`. //! //! Cool neutrals in two appearances and one violet accent; Geist and Geist Mono only, bundled @@ -67,6 +67,8 @@ pub mod icons { "search", "server", "sliders", + // Not Pixelarticons': drawn for Horizon's ratings, in the same 2-unit grid. + "star", "terminal", "trash", "user", @@ -526,7 +528,7 @@ pub fn contrast(a: u32, b: u32) -> f32 { (x.max(y) + 0.05) / (x.min(y) + 0.05) } -/// `website/src/tokens.css`: every token as a CSS custom property, the light palette by +/// `tokens.css`: every token as a CSS custom property, the light palette by /// default, the dark one when the system asks for it or the page sets `data-theme="dark"`. pub fn css() -> String { let mut s = String::new(); @@ -769,15 +771,15 @@ mod tests { assert!((bezier([0., 0., 1., 1.], 0.3) - 0.3).abs() < 1e-3); } - /// `website/src/tokens.css` is what [`css`] writes; regenerate it with - /// `cargo run -p xtask -- tokens`. + /// `tokens.css` (beside Cargo.toml, read by the website's build) is what [`css`] writes; + /// regenerate it with `cargo run -p xtask -- tokens`. #[test] - fn website_tokens_are_up_to_date() { - let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../website/src/tokens.css"); + fn tokens_css_is_up_to_date() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tokens.css"); let written = std::fs::read_to_string(path).unwrap_or_default(); assert!( written == css(), - "website/src/tokens.css is out of date: run `cargo run -p xtask -- tokens`" + "crates/horizon-theme/tokens.css is out of date: run `cargo run -p xtask -- tokens`" ); } } diff --git a/website/src/tokens.css b/crates/horizon-theme/tokens.css similarity index 100% rename from website/src/tokens.css rename to crates/horizon-theme/tokens.css diff --git a/crates/horizon-world/examples/probe/main.rs b/crates/horizon-world/examples/probe/main.rs index 479a3ed23..b87e5620a 100644 --- a/crates/horizon-world/examples/probe/main.rs +++ b/crates/horizon-world/examples/probe/main.rs @@ -169,6 +169,7 @@ mod stored_effect_attributes_fixture; mod stored_effect_notifications_fixture; mod structure_density_fixture; mod structure_pieces; +mod structure_start_nbt; mod suffocation_fixture; mod swimming_fixture; mod synced_bytes_fixture; @@ -340,6 +341,7 @@ probes! { stored_effect_notifications_fixture, structure_density_fixture, structure_pieces, + structure_start_nbt, suffocation_fixture, swimming_fixture, synced_bytes_fixture, diff --git a/crates/horizon-world/examples/probe/structure_pieces.rs b/crates/horizon-world/examples/probe/structure_pieces.rs index ebbad9c7a..3b1415da5 100644 --- a/crates/horizon-world/examples/probe/structure_pieces.rs +++ b/crates/horizon-world/examples/probe/structure_pieces.rs @@ -21,7 +21,7 @@ use std::{fs, path::Path}; type Error = Box; -fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> { +pub fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> { let facts: Value = serde_json::from_str(&fs::read_to_string(resources.join("facts.json"))?)?; let states = facts["states"].as_array().ok_or("facts states")?; let registry = BlockRegistry::from_nbt(&Tag::compound([ diff --git a/crates/horizon-world/examples/probe/structure_start_nbt.rs b/crates/horizon-world/examples/probe/structure_start_nbt.rs new file mode 100644 index 000000000..daea17314 --- /dev/null +++ b/crates/horizon-world/examples/probe/structure_start_nbt.rs @@ -0,0 +1,74 @@ +//! The structure starts a chunk owns, as `StructureStart.createTag` saves them. +//! +//! structure_start_nbt ... +//! prints `nbt CX CZ TAG` for every start whose chunk is a target, the tag +//! with sorted keys and typed numbers (VanillaStructureStartOracle's +//! `canonical`), or `unsaved CX CZ ID` when Horizon cannot save it yet. +use horizon_core::position::ChunkPos; +use horizon_world::{ + nbt::Tag, + worldgen::generator::{GeneratorData, OverworldGenerator}, +}; + +type Error = Box; + +/// `VanillaStructureStartOracle.canonical`. +fn canonical(tag: &Tag) -> String { + let join = |parts: Vec| parts.join(","); + match tag { + Tag::Compound(c) => { + let mut keys: Vec<_> = c.keys().map(|k| (k.to_string_lossy(), k)).collect(); + keys.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16())); + let parts = keys + .into_iter() + .map(|(name, key)| format!("{name}:{}", canonical(&c[key]))) + .collect(); + format!("{{{}}}", join(parts)) + } + Tag::List(l) => format!("[{}]", join(l.iter().map(canonical).collect())), + Tag::Byte(v) => format!("{v}b"), + Tag::Short(v) => format!("{v}s"), + Tag::Int(v) => v.to_string(), + Tag::Long(v) => format!("{v}L"), + Tag::Float(bits) => format!("{}f", *bits as i32), + Tag::Double(bits) => format!("{}d", *bits as i64), + Tag::String(s) => format!( + "\"{}\"", + s.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + ), + Tag::IntArray(a) => format!("[I;{}]", join(a.iter().map(i32::to_string).collect())), + Tag::LongArray(a) => format!("[L;{}]", join(a.iter().map(i64::to_string).collect())), + Tag::ByteArray(a) => format!( + "[B;{}]", + join(a.iter().map(|b| (*b as i8).to_string()).collect()) + ), + Tag::End => "END".into(), + } +} + +pub fn main() -> Result<(), Error> { + let mut args = crate::args().skip(1); + let resources = std::path::PathBuf::from(args.next().ok_or("client resource directory")?); + let seed: i64 = args.next().ok_or("seed")?.parse()?; + let dimension = format!("minecraft:{}", args.next().ok_or("dimension")?); + let registry = crate::structure_pieces::registry(&resources)?; + let data = GeneratorData::load(&resources.join("worldgen"), registry)?; + let generator = OverworldGenerator::for_dimension(data, seed, "minecraft:normal", &dimension)?; + let mut worker = generator.worker(); + for target in args { + let (x, z) = target.split_once(',').ok_or("target cx,cz")?; + let chunk = ChunkPos::new(x.parse()?, z.parse()?); + for start in worker.structure_starts(chunk) { + if start.chunk != chunk { + continue; + } + match start.save() { + Some(tag) => println!("nbt {} {} {}", chunk.x, chunk.z, canonical(&tag)), + None => println!("unsaved {} {} {}", chunk.x, chunk.z, start.name), + } + } + } + Ok(()) +} diff --git a/crates/horizon-world/src/level/data.rs b/crates/horizon-world/src/level/data.rs index e0ef0dca1..4635e8b05 100644 --- a/crates/horizon-world/src/level/data.rs +++ b/crates/horizon-world/src/level/data.rs @@ -16,7 +16,7 @@ use serde_json::Value; use std::{ collections::HashMap, fs::{self, File}, - io::{self, BufReader}, + io, path::Path, sync::Arc, }; @@ -582,8 +582,10 @@ fn fail(message: &str) -> io::Error { invalid(message) } +/// From the file's bytes in memory: several times faster than through a +/// reader on gameplay-facts.json (55 MB), which a first start parses. fn read_json(path: &Path) -> io::Result { - serde_json::from_reader(BufReader::new(File::open(path)?)).map_err(|e| invalid(e.to_string())) + serde_json::from_slice(&fs::read(path)?).map_err(|e| invalid(e.to_string())) } fn interned(cache: &mut HashMap>, text: &str) -> Arc { diff --git a/crates/horizon-world/src/level/hooks.rs b/crates/horizon-world/src/level/hooks.rs index 7f9e8d19a..f716449f5 100644 --- a/crates/horizon-world/src/level/hooks.rs +++ b/crates/horizon-world/src/level/hooks.rs @@ -447,6 +447,13 @@ impl Sim<'_, L> { if !b.table.bool(state, b.props.powered) { self.detector_rail_check_pressed(pos, state); } + } else if data.is(Class::Button) { + // ButtonBlock.entityInside: a wooden button an arrow is inside. + if data.set_type.as_ref().is_some_and(|s| s.arrows) + && !b.table.bool(state, b.props.powered) + { + self.button_check_pressed(pos, state); + } } else if data.is(Class::TripWire) && !b.table.bool(state, b.props.powered) && !self.ticks.has_block_tick(pos, b.block(state)) diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index 7b2195c34..831dea544 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -195,6 +195,7 @@ impl Sim<'_, L> { let next = table.cycle(state, p.inverted); self.set_block(pos, next, 2); self.game_event(pos, "minecraft:block_change"); + self.daylight_detector_update_signal(pos, next); Success } Class::Cake => { @@ -227,19 +228,7 @@ impl Sim<'_, L> { if table.int(state, p.level) != 8 { return Pass; } - self.player_effect( - pos, - state, - PlayerEffect::DropItem("minecraft:bone_meal".into()), - ); - self.set_block_and_update(pos, table.set_int(state, p.level, 0)); - self.game_event(pos, "minecraft:block_change"); - self.emit(LevelEvent::Sound { - pos, - sound: "minecraft:block.composter.empty".into(), - volume: 1.0, - pitch: 1.0, - }); + self.composter_extract_produce(pos, state); Success } Class::CaveVines | Class::CaveVinesPlant => { @@ -435,6 +424,25 @@ impl Sim<'_, L> { InteractionResult::Success } + /// `ComposterBlock.extractProduce`: the bone meal pops out, the composter + /// empties and its sound plays. + fn composter_extract_produce(&mut self, pos: BlockPos, state: BlockStateId) { + let b = self.b; + self.player_effect( + pos, + state, + PlayerEffect::DropItem("minecraft:bone_meal".into()), + ); + self.set_block_and_update(pos, b.table.set_int(state, b.props.level, 0)); + self.game_event(pos, "minecraft:block_change"); + self.emit(LevelEvent::Sound { + pos, + sound: "minecraft:block.composter.empty".into(), + volume: 1.0, + pitch: 1.0, + }); + } + /// `ComposterBlock.addLayer` once its `compostable` provider gave /// `layers`: the new state (the old one when no layer is added). fn composter_add_layer(&mut self, pos: BlockPos, layers: i32) -> BlockStateId { @@ -499,6 +507,44 @@ impl Behaviours { next } + /// `ComposterBlock.addLayer` without level event 1500, for a villager + /// that composts several items and sends the event once at the end + /// (`WorkAtComposter.compostItems`). The new state. + pub fn composter_layer( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + layers: i32, + ) -> BlockStateId { + self.sim(level, ticks).composter_add_layer(pos, layers) + } + + /// `ComposterBlock.extractProduce` by a villager (`WorkAtComposter`). + pub fn composter_extract( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + ) { + let mut sim = self.sim(level, ticks); + let state = sim.state(pos); + sim.composter_extract_produce(pos, state); + } + + /// Level event 1500 (`WorkAtComposter.spawnComposterFillEffects`): data 1 + /// when the composter's state changed. + pub fn composter_fill_effects( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + changed: bool, + ) { + self.sim(level, ticks) + .level_event(1500, pos, i32::from(changed)); + } + /// `ComposterBlock.empty`: the hopper output container took the bone meal. pub fn composter_empty( &self, diff --git a/crates/horizon-world/src/level/mod.rs b/crates/horizon-world/src/level/mod.rs index 3831efbd6..0a8e160cd 100644 --- a/crates/horizon-world/src/level/mod.rs +++ b/crates/horizon-world/src/level/mod.rs @@ -114,6 +114,7 @@ mod place; mod portal; pub mod portal_travel; mod query; +pub(crate) use query::data_2d; mod rails; mod redstone; pub mod removal; @@ -180,6 +181,8 @@ pub struct GameRules { pub max_snow_accumulation_height: i32, pub spawn_mobs: bool, pub spawn_monsters: bool, + /// `fire_spread_radius_around_player`: -1 spreads everywhere. + pub fire_spread_radius_around_player: i32, } impl Default for GameRules { fn default() -> Self { @@ -192,6 +195,7 @@ impl Default for GameRules { max_snow_accumulation_height: 1, spawn_mobs: true, spawn_monsters: true, + fire_spread_radius_around_player: 128, } } } @@ -205,6 +209,9 @@ pub enum EntityQuery { Mobs, /// Minecarts only (detector rails). Minecart, + /// Arrows only (`AbstractArrow`, thrown tridents included): the entities + /// a wooden button senses. + Arrows, } /// Side effects reported to the caller, in vanilla call order. @@ -287,6 +294,11 @@ pub enum LevelEvent { /// The world as seen by block behaviour. Implemented by the game and tests. pub trait LevelAccess { + /// `Level.isClientSide`: this level is a client's copy (false on a + /// server, a host or the test harness). + fn is_client_side(&self) -> bool { + false + } /// `getBlockState`. Unloaded or out-of-bounds positions read as air. fn block_state(&self, pos: BlockPos) -> BlockStateId; /// Storage write (`LevelChunkSection.setBlockState`). Returns the previous @@ -319,6 +331,10 @@ pub trait LevelAccess { fn sky_darken(&self) -> i32 { 0 } + /// `EnvironmentAttributes.SUN_ANGLE` at `pos`, in degrees (0 at noon). + fn sun_angle(&self, _pos: BlockPos) -> f32 { + 0.0 + } fn is_raining(&self) -> bool { false } @@ -342,6 +358,11 @@ pub trait LevelAccess { fn can_spread_fire_around(&self, _pos: BlockPos) -> bool { true } + /// The `INCREASED_FIRE_BURNOUT` environment attribute at `pos` (a biome + /// attribute). + fn increased_fire_burnout(&self, _pos: BlockPos) -> bool { + false + } /// `Level.isUnobstructed(state, pos, context)`: whether the state's /// collision shape at `pos` would intersect an entity. fn is_unobstructed(&self, _pos: BlockPos, _state: BlockStateId) -> bool { @@ -749,6 +770,24 @@ impl Behaviours { ) { self.sim(level, ticks).entity_inside(pos); } + /// `DaylightDetectorBlock.tickEntity` of the daylight detector at `pos`: + /// its signal follows the sky every 20 game ticks. The detector's ticker + /// exists only where the dimension has sky light. + pub fn tick_daylight_detector( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + ) { + if level.dimension() == Dimension::Nether || level.game_time() % 20 != 0 { + return; + } + let mut sim = self.sim(level, ticks); + let state = sim.state(pos); + if sim.b.data(state).is(classes::Class::DaylightDetector) { + sim.daylight_detector_update_signal(pos, state); + } + } /// `BigDripleafBlock.entityInside` for an entity whose box is inside /// `pos`: whether it is on the ground and its feet's y (`canEntityTilt`). pub fn entity_on_big_dripleaf( @@ -779,6 +818,22 @@ impl Behaviours { ) -> i32 { self.sim(level, ticks).best_neighbor_signal(pos) } + /// `BlockState.onProjectileHit` of a target block (`TargetBlock`) hit by + /// a projectile at `location` on the face with normal `face`; `arrow` + /// for an `AbstractArrow`. Returns the output strength, or None when the + /// block at `pos` is not a target block. + pub fn target_projectile_hit( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + location: [f64; 3], + face: [i32; 3], + arrow: bool, + ) -> Option { + self.sim(level, ticks) + .target_projectile_hit(pos, location, face, arrow) + } } /// One mutable view used by the ported behaviour code. diff --git a/crates/horizon-world/src/level/pistons.rs b/crates/horizon-world/src/level/pistons.rs index 648d6d543..45e469efd 100644 --- a/crates/horizon-world/src/level/pistons.rs +++ b/crates/horizon-world/src/level/pistons.rs @@ -27,6 +27,9 @@ pub struct MovingPiston { pub progress: f32, pub progress_o: f32, pub last_ticked: i64, + /// `PistonMovingBlockEntity.deathTicks`: on a client the entity waits + /// five ticks past progress 1.0 before placing the block itself. + pub death_ticks: i32, /// Identity of this block entity (a replaced entity is a new ticker). pub id: u64, } @@ -126,11 +129,8 @@ impl Sim<'_, L> { self.note_trigger_event(pos, state); true } else if data.is(super::classes::Class::Bell) { - // BellBlockEntity.triggerEvent handles event 1; the server - // broadcasts handled events to the clients. - if a == 1 { - self.emit(LevelEvent::BlockEntityEvent { pos, state, a, b }); - } + // BellBlockEntity.triggerEvent handles event 1; the caller + // reports handled events (`runBlockEvents` broadcasts them). a == 1 } else { false @@ -164,7 +164,9 @@ impl Sim<'_, L> { let Some(piston) = self.moving_piston(pos) else { return; }; - if piston.progress_o >= 1.0 { + // finalTick: `progressO < 1.0 || level.isClientSide` — a client + // finishes its copy even at full progress. + if piston.progress_o >= 1.0 && !self.level.is_client_side() { return; } self.remove_moving_piston(pos); @@ -189,6 +191,20 @@ impl Sim<'_, L> { piston.last_ticked = self.level.game_time(); piston.progress_o = piston.progress; if piston.progress_o >= 1.0 { + if self.level.is_client_side() && piston.death_ticks < 5 { + // The client's copy waits for the server's state before + // placing the block itself (deathTicks < 5). + piston.death_ticks += 1; + if let Some(entry) = self + .ticks + .moving_pistons + .iter_mut() + .find(|(p, _)| *p == pos) + { + entry.1 = piston; + } + return; + } self.remove_moving_piston(pos); if b.is_block(self.state(pos), b.named.moving_piston) { let mut new = self.update_from_neighbour_shapes(piston.moved_state, pos); @@ -241,8 +257,13 @@ impl Sim<'_, L> { direction != Direction::Down && self.has_signal(rel(up, direction), direction) }) } - /// `PistonBaseBlock.checkIfExtend`. + /// `PistonBaseBlock.checkIfExtend`, which `neighborChanged`, `onPlace` + /// and `setPlacedBy` call only server-side: a client moves its pistons on + /// the server's block events alone. pub(crate) fn piston_check_if_extend(&mut self, pos: BlockPos, state: BlockStateId) { + if self.level.is_client_side() { + return; + } let b = self.b; let direction = b.facing(state); let extend = self.piston_neighbor_signal(pos, direction); @@ -527,6 +548,12 @@ impl Sim<'_, L> { for pos in to_destroy.iter().rev() { let state = self.state(*pos); self.drop_resources(state, *pos); + // The client's copy plays the destroy effect itself (the + // server sends no 2001 for piston moves). + let fire = b.is_block(state, b.named.fire) || b.is_block(state, b.named.soul_fire); + if !fire && self.level.is_client_side() { + self.level_event(2001, *pos, state.0 as i32); + } self.set_block(*pos, b.air(), 18); self.game_event(*pos, "minecraft:block_destroy"); to_update.push(state); @@ -551,6 +578,7 @@ impl Sim<'_, L> { progress: 0.0, progress_o: 0.0, last_ticked: 0, + death_ticks: 0, id: 0, }, ); @@ -576,6 +604,7 @@ impl Sim<'_, L> { progress: 0.0, progress_o: 0.0, last_ticked: 0, + death_ticks: 0, id: 0, }, ); @@ -624,13 +653,17 @@ impl Sim<'_, L> { let direction = b.facing(state); let extended_state = b.table.set_bool(state, p.extended, true); let sticky = self.is_sticky_piston(state); - let extend = self.piston_neighbor_signal(pos, direction); - if extend && (a == 1 || a == 2) { - self.set_block(pos, extended_state, 2); - return false; - } - if !extend && a == 0 { - return false; + // The signal re-check is server-only (`!level.isClientSide`): a + // client trusts the event as sent. + if !self.level.is_client_side() { + let extend = self.piston_neighbor_signal(pos, direction); + if extend && (a == 1 || a == 2) { + self.set_block(pos, extended_state, 2); + return false; + } + if !extend && a == 0 { + return false; + } } if a == 0 { if !self.piston_move_blocks(pos, direction, true, sticky) { @@ -664,6 +697,7 @@ impl Sim<'_, L> { progress: 0.0, progress_o: 0.0, last_ticked: 0, + death_ticks: 0, id: 0, }, ); @@ -748,8 +782,18 @@ impl Behaviours { } let mut sim = self.sim(level, ticks); let state = sim.state(event.pos); - if self.block(state) == event.block { - sim.trigger_event(state, event.pos, event.param_a, event.param_b); + if self.block(state) == event.block + && sim.trigger_event(state, event.pos, event.param_a, event.param_b) + { + // ServerLevel.runBlockEvents broadcasts a handled event to + // the clients (`ClientboundBlockEventPacket`); their copy of + // the block re-runs `triggerEvent` (a piston's `moveBlocks`). + sim.emit(LevelEvent::BlockEntityEvent { + pos: event.pos, + state, + a: event.param_a, + b: event.param_b, + }); } } for event in reschedule { @@ -779,4 +823,85 @@ impl Behaviours { self.sim(level, ticks).moving_piston_tick(pos); } } + /// `ClientLevel.blockEvent`: the client re-runs the block's + /// `triggerEvent` on its own level (a piston's `moveBlocks` builds the + /// moving block entities it then animates) instead of waiting for the + /// server's states. No queue and no ticking-chunk gate: a client runs + /// the event where the packet lands. + pub fn client_block_event( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + a: i32, + b: i32, + ) -> bool { + let triggered = { + let mut sim = self.sim(level, ticks); + let state = sim.state(pos); + let triggered = sim.trigger_event(state, pos, a, b); + if triggered { + // The client's copy of a handled event reaches the client + // copy of the block entity (a bell's ring). + sim.emit(LevelEvent::BlockEntityEvent { pos, state, a, b }); + } + triggered + }; + self.client_drain_block_events(level, ticks); + triggered + } + /// `ClientLevel.tickBlockEntities` for moving pistons: the client copy of + /// [`Behaviours::tick_block_entities`], without the server's + /// ticking-chunk gate (a client ticks every loaded block entity). One + /// whose block was replaced is dropped (`TickingBlockEntity.isRemoved`). + pub fn client_tick_block_entities( + &self, + level: &mut L, + ticks: &mut Ticks, + ) { + let tickers: Vec<(BlockPos, u64)> = ticks + .moving_pistons + .iter() + .map(|(p, m)| (*p, m.id)) + .collect(); + for (pos, id) in tickers { + if !ticks + .moving_pistons + .iter() + .any(|(p, m)| *p == pos && m.id == id) + { + continue; + } + { + let mut sim = self.sim(level, ticks); + let state = sim.state(pos); + if sim.b.is_block(state, sim.b.named.moving_piston) { + sim.moving_piston_tick(pos); + } else { + sim.remove_moving_piston(pos); + } + } + self.client_drain_block_events(level, ticks); + } + } + /// Block events a client-side `triggerEvent` queued + /// (`PistonBaseBlock.checkIfExtend` in a neighbour update): a client + /// runs them as they come (`ClientLevel.blockEvent`), not in a tick pass. + fn client_drain_block_events(&self, level: &mut L, ticks: &mut Ticks) { + while let Some(event) = ticks.block_events.first().copied() { + ticks.block_events.remove(0); + let mut sim = self.sim(level, ticks); + let state = sim.state(event.pos); + if self.block(state) == event.block + && sim.trigger_event(state, event.pos, event.param_a, event.param_b) + { + sim.emit(LevelEvent::BlockEntityEvent { + pos: event.pos, + state, + a: event.param_a, + b: event.param_b, + }); + } + } + } } diff --git a/crates/horizon-world/src/level/redstone.rs b/crates/horizon-world/src/level/redstone.rs index 92f9059e5..b07f8c6a2 100644 --- a/crates/horizon-world/src/level/redstone.rs +++ b/crates/horizon-world/src/level/redstone.rs @@ -11,7 +11,10 @@ use super::{ }; use crate::registry::BlockStateId; use horizon_core::{ - geometry::Direction, position::BlockPos, random::RandomSource, ticks::TickPriority, + geometry::{Direction, trig}, + position::BlockPos, + random::RandomSource, + ticks::TickPriority, }; const SIGNAL_ORDER: [Direction; 6] = [ @@ -879,18 +882,109 @@ impl Sim<'_, L> { } self.game_event(pos, "minecraft:block_activate"); } - /// `ButtonBlock.checkPressed` with no arrows present. + /// `ButtonBlock.tick`. pub(crate) fn button_tick(&mut self, pos: BlockPos, state: BlockStateId) { let b = self.b; - if !b.table.bool(state, b.props.powered) { - return; + if b.table.bool(state, b.props.powered) { + self.button_check_pressed(pos, state); } - self.set_block_and_update(pos, b.table.set_bool(state, b.props.powered, false)); - self.attached_update_neighbours(pos, state); - if let Some(set) = b.data(state).set_type.clone() { - self.sound(pos, &set.button_off, 1.0, 1.0); + } + /// `ButtonBlock.checkPressed`: a button whose type allows arrows is + /// pressed while an arrow is inside its shape, and stays pressed (the + /// release is scheduled again) as long as one is. + pub(crate) fn button_check_pressed(&mut self, pos: BlockPos, state: BlockStateId) { + let b = self.b; + let data = b.data(state); + let should_be_pressed = data.set_type.as_ref().is_some_and(|s| s.arrows) + && self + .level + .count_entities(self.button_bounds(pos, state), super::EntityQuery::Arrows) + > 0; + let was_pressed = b.table.bool(state, b.props.powered); + if should_be_pressed != was_pressed { + self.set_block_and_update( + pos, + b.table.set_bool(state, b.props.powered, should_be_pressed), + ); + self.attached_update_neighbours(pos, state); + if let Some(set) = data.set_type.clone() { + let sound = if should_be_pressed { + &set.button_on + } else { + &set.button_off + }; + self.sound(pos, sound, 1.0, 1.0); + } + self.game_event( + pos, + if should_be_pressed { + "minecraft:block_activate" + } else { + "minecraft:block_deactivate" + }, + ); + } + if should_be_pressed { + self.schedule_tick(pos, b.block(state), data.ticks_to_stay_pressed); + } + } + /// `ButtonBlock.getShape(...).bounds().move(pos)`: a 6 by 4 plate on the + /// face it is attached to, 2 pixels thick (1 when pressed). + fn button_bounds(&self, pos: BlockPos, state: BlockStateId) -> horizon_core::geometry::Aabb { + use horizon_core::geometry::{Aabb, Vec3}; + let b = self.b; + let thickness = if b.table.bool(state, b.props.powered) { + 1.0 / 16.0 + } else { + 2.0 / 16.0 + }; + let attached = self.face_attached_connected(state).opposite().normal(); + let thick = attached.iter().position(|&n| n != 0).unwrap_or(1); + // The 6 pixel side lies across the button's facing. + let wide = if b.facing(state).normal()[2] != 0 { + 0 + } else { + 2 + }; + let tall = 3 - thick - wide; + let (mut lo, mut hi) = ([0.0; 3], [1.0; 3]); + if attached[thick] > 0 { + lo[thick] = 1.0 - thickness; + } else { + hi[thick] = thickness; + } + (lo[wide], hi[wide]) = (5.0 / 16.0, 11.0 / 16.0); + (lo[tall], hi[tall]) = (6.0 / 16.0, 10.0 / 16.0); + let origin = [f64::from(pos.x), f64::from(pos.y), f64::from(pos.z)]; + Aabb::new( + Vec3::new(origin[0] + lo[0], origin[1] + lo[1], origin[2] + lo[2]), + Vec3::new(origin[0] + hi[0], origin[1] + hi[1], origin[2] + hi[2]), + ) + } + /// `DaylightDetectorBlock.updateSignalStrength`: the effective sky light + /// at the detector, shaped by the sun's angle (or inverted), as its + /// power. + pub(crate) fn daylight_detector_update_signal(&mut self, pos: BlockPos, state: BlockStateId) { + let b = self.b; + let mut target = (self.level.sky_light(pos) - self.level.sky_darken()).max(0); + if b.table.bool(state, b.props.inverted) { + target = 15 - target; + } else if target > 0 { + let mut sun_angle = self.level.sun_angle(pos) * trig::DEG_TO_RAD; + let offset = if sun_angle < std::f32::consts::PI { + 0.0 + } else { + std::f32::consts::TAU + }; + sun_angle += (offset - sun_angle) * 0.2; + // Math.round(float). + let scaled = target as f32 * trig::cos(f64::from(sun_angle)); + target = (f64::from(scaled) + 0.5).floor() as i32; + } + let target = target.clamp(0, 15); + if b.table.int(state, b.props.power) != target { + self.set_block_and_update(pos, b.table.set_int(state, b.props.power, target)); } - self.game_event(pos, "minecraft:block_deactivate"); } /// `LeverBlock.pull`. pub(crate) fn lever_pull(&mut self, pos: BlockPos, state: BlockStateId) { @@ -1187,6 +1281,43 @@ impl Sim<'_, L> { self.set_block(pos, next, 2); } } + /// `TargetBlock.onProjectileHit` for a projectile that hit `face` of the + /// block at `pos` at `location`: the output power from how near the + /// face's centre it landed (1 to 15), reset after 20 ticks for an arrow + /// and 8 for anything else, unless a reset is already scheduled. None + /// when the block is not a target block, else the strength. + pub(crate) fn target_projectile_hit( + &mut self, + pos: BlockPos, + location: [f64; 3], + face: [i32; 3], + arrow: bool, + ) -> Option { + let b = self.b; + let state = self.state(pos); + if !b.data(state).is(Class::Target) { + return None; + } + // `getRedstoneStrength`: the distance from the centre along the + // face's two other axes (Mth.frac of the location, minus a half). + let off = |v: f64| (v - v.floor() - 0.5).abs(); + let [dx, dy, dz] = [off(location[0]), off(location[1]), off(location[2])]; + let distance = if face[1] != 0 { + dx.max(dz) + } else if face[2] != 0 { + dx.max(dy) + } else { + dy.max(dz) + }; + let strength = ((15.0 * ((0.5 - distance) / 0.5).clamp(0.0, 1.0)).ceil() as i32).max(1); + let block = b.block(state); + if !self.ticks.has_block_tick(pos, block) { + // `setOutputPower`. + self.set_block_and_update(pos, b.table.set_int(state, b.props.power, strength)); + self.schedule_tick(pos, block, if arrow { 20 } else { 8 }); + } + Some(strength) + } } /// Order `positions` as a `java.util.HashSet` of capacity 16 would diff --git a/crates/horizon-world/src/level/tests.rs b/crates/horizon-world/src/level/tests.rs index 8a4b6c34f..da75083d2 100644 --- a/crates/horizon-world/src/level/tests.rs +++ b/crates/horizon-world/src/level/tests.rs @@ -47,6 +47,8 @@ pub(crate) struct TestLevel { pub day_time: i64, pub events: Vec, pub block_light: HashMap, + /// `Level.isClientSide`. + pub client_side: bool, } impl TestLevel { fn new(b: &Behaviours) -> Self { @@ -58,10 +60,14 @@ impl TestLevel { day_time: 0, events: Vec::new(), block_light: HashMap::new(), + client_side: false, } } } impl LevelAccess for TestLevel { + fn is_client_side(&self) -> bool { + self.client_side + } fn block_state(&self, pos: BlockPos) -> BlockStateId { self.blocks.get(&pos).copied().unwrap_or(self.air) } @@ -919,6 +925,100 @@ fn pistons_push_and_sticky_pistons_pull() { assert_eq!(w.get(BlockPos::new(2, 1, 0)), air); } +/// `ServerLevel.runBlockEvents` reports a handled event for the clients +/// (`ClientboundBlockEventPacket`), carrying the block the event named. +#[test] +fn a_handled_block_event_reaches_the_clients() { + let mut w = world!(); + w.floor(0, 10, "stone"); + let piston = w.id("piston", &[("facing", "east")]); + let block = w.id("redstone_block", &[]); + w.set(BlockPos::new(0, 1, 0), piston); + w.set(BlockPos::new(-1, 1, 0), block); + w.level.events.clear(); + w.full_tick(); + assert!(w.level.events.iter().any(|e| matches!( + e, + LevelEvent::BlockEntityEvent { + pos, + state, + a: 0, + .. + } if *pos == BlockPos::new(0, 1, 0) && *state == piston + ))); +} + +/// `ClientLevel.blockEvent`: the client re-runs `triggerEvent` on its own +/// level, which builds the moving block entities a piston's `moveBlocks` +/// makes, and ticks them to the placed block. +#[test] +fn a_client_replays_a_pistons_block_event() { + let mut w = world!(); + w.floor(0, 10, "stone"); + let piston = w.id("sticky_piston", &[("facing", "east")]); + let dirt = w.id("dirt", &[]); + let block = w.id("redstone_block", &[]); + w.put(BlockPos::new(0, 1, 0), piston); + w.put(BlockPos::new(1, 1, 0), dirt); + w.put(BlockPos::new(-1, 1, 0), block); + w.f.behaviours.client_block_event( + &mut w.level, + &mut w.ticks, + BlockPos::new(0, 1, 0), + 0, + Direction::East as i32, + ); + assert!( + w.ticks + .moving_pistons + .iter() + .any(|(p, _)| *p == BlockPos::new(2, 1, 0)) + ); + for _ in 0..3 { + w.level.time += 1; + w.f.behaviours + .client_tick_block_entities(&mut w.level, &mut w.ticks); + } + assert_eq!(w.get(BlockPos::new(2, 1, 0)), dirt); + assert_eq!( + w.name(BlockPos::new(1, 1, 0)), + "piston_head[facing=east,short=false,type=sticky]" + ); + assert!(w.ticks.moving_pistons.is_empty()); +} + +/// A client's copy of a move doesn't start the pistons the moved block +/// powers (`checkIfExtend` is server-only): their own block events come from +/// the server. +#[test] +fn a_client_replay_starts_no_other_piston() { + let mut w = world!(); + w.floor(0, 10, "stone"); + w.level.client_side = true; + let piston = w.id("piston", &[("facing", "east")]); + let block = w.id("redstone_block", &[]); + let up = w.id("piston", &[("facing", "up")]); + w.put(BlockPos::new(0, 1, 0), piston); + w.put(BlockPos::new(1, 1, 0), block); + w.put(BlockPos::new(2, 2, 0), up); + w.f.behaviours.client_block_event( + &mut w.level, + &mut w.ticks, + BlockPos::new(0, 1, 0), + 0, + Direction::East as i32, + ); + for _ in 0..10 { + w.level.time += 1; + w.f.behaviours + .client_tick_block_entities(&mut w.level, &mut w.ticks); + } + assert_eq!(w.get(BlockPos::new(2, 1, 0)), block); + assert_eq!(w.get(BlockPos::new(2, 2, 0)), up); + assert!(w.ticks.moving_pistons.is_empty()); + assert!(w.ticks.block_events.is_empty()); +} + #[test] fn pistons_pop_fragile_blocks_and_refuse_obsidian() { let mut w = world!(); diff --git a/crates/horizon-world/src/level/tick.rs b/crates/horizon-world/src/level/tick.rs index 29f53e053..25e2a19d3 100644 --- a/crates/horizon-world/src/level/tick.rs +++ b/crates/horizon-world/src/level/tick.rs @@ -565,9 +565,8 @@ impl Sim<'_, L> { return; } } - // FireBlock.tick reads the INCREASED_FIRE_BURNOUT environment attribute - // (jungles, swamps, peaks, mushroom fields); Horizon doesn't yet. - let burnout = false; + // FireBlock.tick: the INCREASED_FIRE_BURNOUT environment attribute. + let burnout = self.level.increased_fire_burnout(pos); let extra = if burnout { -50 } else { 0 }; for (direction, chance) in [ (Direction::East, 300), diff --git a/crates/horizon-world/src/living_motion.rs b/crates/horizon-world/src/living_motion.rs index b44da8b7b..9c496ed63 100644 --- a/crates/horizon-world/src/living_motion.rs +++ b/crates/horizon-world/src/living_motion.rs @@ -122,3 +122,119 @@ pub fn air_drag(world: &mut W) -> Result { }; Ok(modified_friction(drag, world.air_drag_modifier()? as f32)) } + +#[cfg(test)] +mod tests { + use super::*; + use horizon_core::geometry::{Vec3, trig}; + + struct Jumper { + velocity: Vec3, + sprinting: bool, + yaw: f32, + synced: bool, + } + impl LivingMotionWorld for Jumper { + type Error = (); + fn velocity(&self) -> Vec3 { + self.velocity + } + fn set_velocity(&mut self, velocity: Vec3) -> Result<(), ()> { + self.velocity = velocity; + Ok(()) + } + fn jump_strength(&mut self) -> Result { + Ok(0.42) + } + fn block_jump_factor(&mut self) -> Result { + Ok(1.0) + } + fn has_jump_boost(&mut self) -> Result { + Ok(false) + } + fn jump_boost_amplifier(&mut self) -> Result { + Ok(0) + } + fn sprinting(&mut self) -> Result { + Ok(self.sprinting) + } + fn yaw(&mut self) -> Result { + Ok(self.yaw) + } + fn mark_needs_sync(&mut self) { + self.synced = true; + } + fn no_gravity(&mut self) -> Result { + Ok(false) + } + fn gravity_attribute(&mut self) -> Result { + Ok(0.08) + } + fn has_slow_falling(&mut self) -> Result { + Ok(false) + } + fn controlling_passenger_is_player(&mut self) -> Result { + Ok(false) + } + fn speed(&mut self) -> Result { + Ok(0.1) + } + fn omnidirectional_air_mover(&mut self) -> Result { + Ok(false) + } + fn air_drag_modifier(&mut self) -> Result { + Ok(0.0) + } + } + + fn impulse(yaw: f32) -> (f64, f64) { + let angle = f64::from(yaw * trig::DEG_TO_RAD); + ( + f64::from(-trig::sin(angle)) * 0.2, + f64::from(trig::cos(angle)) * 0.2, + ) + } + + /// LivingEntity.jumpFromGround: a sprint adds ~0.2 along look; a walk does not. + #[test] + fn sprint_jump_adds_look_direction_impulse() { + for yaw in [0.0, 45.0, 90.0, 180.0, 270.0, 300.0] { + let before = Vec3::new(0.13, -0.08, -0.05); + let mut sprinting = Jumper { + velocity: before, + sprinting: true, + yaw, + synced: false, + }; + jump_from_ground(&mut sprinting).unwrap(); + let (dx, dz) = impulse(yaw); + let horizontal = ((dx * dx) + (dz * dz)).sqrt(); + assert!( + (horizontal - 0.2).abs() < 1.0e-4, + "yaw {yaw}: impulse length {horizontal}" + ); + assert!( + (sprinting.velocity.x - (before.x + dx)).abs() < 1.0e-9, + "yaw {yaw}: x {:?} expected {}", + sprinting.velocity, + before.x + dx + ); + assert!((sprinting.velocity.z - (before.z + dz)).abs() < 1.0e-9); + // jump_power narrows the 0.42 strength through float, as vanilla does. + let jump_y = f64::from(0.42_f32); + assert!((sprinting.velocity.y - jump_y).abs() < 1.0e-9); + assert!(sprinting.synced); + + let mut walking = Jumper { + velocity: before, + sprinting: false, + yaw, + synced: false, + }; + jump_from_ground(&mut walking).unwrap(); + assert_eq!(walking.velocity.x, before.x); + assert_eq!(walking.velocity.z, before.z); + assert!((walking.velocity.y - jump_y).abs() < 1.0e-9); + } + } +} diff --git a/crates/horizon-world/src/loot.rs b/crates/horizon-world/src/loot.rs index 96ca5bdee..399c76f69 100644 --- a/crates/horizon-world/src/loot.rs +++ b/crates/horizon-world/src/loot.rs @@ -31,6 +31,9 @@ pub struct LootStack { pub exploration: Option>, /// Set by set_ominous_bottle_amplifier. pub ominous_amplifier: Option, + /// Added by set_stew_effect (the `suspicious_stew_effects` component): + /// (effect id, duration in ticks). + pub stew_effects: Vec<(String, i32)>, } /// `ExplorationMapFunction`'s settings. #[derive(Clone, Debug, PartialEq, Eq)] @@ -74,6 +77,7 @@ impl LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), } } pub fn is_empty(&self) -> bool { @@ -150,6 +154,8 @@ pub struct LootEntity { pub captain: Option, /// A frog's variant id (`minecraft:warm`). pub frog_variant: Option, + /// A chicken's variant id (`minecraft:warm`). + pub chicken_variant: Option, /// A charged creeper (`Creeper.isPowered`). pub powered: bool, /// Worn and held items by `EquipmentSlot` name (mainhand, offhand, @@ -678,6 +684,13 @@ impl LootData { } } +/// `MobEffect.isInstantaneous`. +fn is_instantaneous(effect: &str) -> bool { + matches!( + effect, + "minecraft:saturation" | "minecraft:instant_health" | "minecraft:instant_damage" + ) +} /// The level of one enchantment on a stack (its `enchantments` component). fn item_level(stack: &LootStack, enchantment: &str) -> i32 { stack @@ -851,6 +864,9 @@ impl Context<'_, R> { "minecraft:frog/variant" => { e.frog_variant.is_some() && want.as_str() == e.frog_variant.as_deref() } + "minecraft:chicken/variant" => { + e.chicken_variant.is_some() && want.as_str() == e.chicken_variant.as_deref() + } _ => false, }), "minecraft:type_specific/sheep" => e @@ -1134,7 +1150,14 @@ impl Context<'_, R> { .random .next_int_bound(effects.len() as i32) .unwrap_or(0) as usize; - self.int(&effects[pick]["duration"]); + let effect = effects[pick]["type"].as_str().unwrap_or(""); + let mut duration = self.int(&effects[pick]["duration"]); + // Seconds, unless the effect is instantaneous. + if !is_instantaneous(effect) { + duration *= 20; + } + // SuspiciousStewEffects.withEffectAdded + stack.stew_effects.push((effect.to_owned(), duration)); } } "minecraft:set_ominous_bottle_amplifier" => { diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index 316bd837c..7a38608be 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -269,6 +269,20 @@ fn quantize_parameter_report(json: &str) -> Result { } Ok(Value::Array(entries).to_string()) } +/// `load` on a thread of its own ("Worldgen loader"). +fn loading( + load: impl FnOnce() -> T + Send + 'static, +) -> io::Result> { + std::thread::Builder::new() + .name("Worldgen loader".into()) + .spawn(load) +} +/// A loading thread's result; its panic goes on in this thread. +fn joined(thread: std::thread::JoinHandle) -> T { + thread + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)) +} impl GeneratorData { /// Load a worldgen bundle directory. Required files are JSON objects mapping /// resource identifiers to the resource's JSON text: `noises`, `densities`, @@ -290,6 +304,50 @@ impl GeneratorData { let block_tags = build_tags(&read_bundle(dir, "block_tags")?, |id| { registry.default_state(id).is_some() })?; + let fluid_tags = match read_optional_bundle(dir, "fluid_tags")? { + Some(raw) => build_tags(&raw, |id| { + matches!( + id, + "minecraft:water" + | "minecraft:flowing_water" + | "minecraft:lava" + | "minecraft:flowing_lava" + | "minecraft:empty" + ) + })?, + None => BTreeMap::new(), + }; + // The three biggest reads (the block tables, the structure templates + // and the block behaviours) need only the tags: they run on threads of + // their own while this one reads the rest. A new world's first chunks + // wait for all of it, with the chunk workers idle. + let blocks = loading({ + let (dir, tags, fluids) = (dir.join("blocks"), block_tags.clone(), fluid_tags.clone()); + move || super::generator_blocks::BlockBehavior::load(&dir, registry, &tags, &fluids) + })?; + let structures = loading({ + let (dir, tags) = (dir.to_owned(), block_tags.clone()); + move || -> Result<_, GeneratorError> { + let Ok(json) = fs::read_to_string(dir.join("blocks/structure-blocks.json")) else { + return Ok(None); + }; + Ok(Some(Arc::new(super::structure::StructureResources::new( + registry, + super::structure::StateTransforms::from_json(&json, registry.len())?, + &dir.join("structure"), + read_optional_bundle(&dir, "processor_lists")?.unwrap_or_default(), + &tags, + )))) + } + })?; + let behaviours = loading({ + let root = dir.parent().map(Path::to_owned); + move || { + root.filter(|root| root.join("block-behaviours.json").exists()) + .and_then(|root| crate::level::Behaviours::load(registry, &root).ok()) + .map(Arc::new) + } + })?; let tag_set = |name: &str| -> Result, GeneratorError> { Ok(block_tags .get(name) @@ -382,47 +440,17 @@ impl GeneratorData { read_optional_bundle(dir, "features")?, read_optional_bundle(dir, "placed_features")?, ) { - (Some(features), Some(placed)) => { - let fluid_tags = match read_optional_bundle(dir, "fluid_tags")? { - Some(raw) => build_tags(&raw, |id| { - matches!( - id, - "minecraft:water" - | "minecraft:flowing_water" - | "minecraft:lava" - | "minecraft:flowing_lava" - | "minecraft:empty" - ) - })?, - None => BTreeMap::new(), - }; - Some(FeatureResources { - features, - placed, - feature_tags: read_optional_bundle(dir, "feature_tags")?.unwrap_or_default(), - blocks: super::generator_blocks::BlockBehavior::load( - &dir.join("blocks"), - registry, - &block_tags, - &fluid_tags, - )?, - fluid_tags, - state_providers: read_optional_bundle(dir, "state_providers")? - .unwrap_or_default(), - }) - } + (Some(features), Some(placed)) => Some(FeatureResources { + features, + placed, + feature_tags: read_optional_bundle(dir, "feature_tags")?.unwrap_or_default(), + blocks: joined(blocks)?, + fluid_tags, + state_providers: read_optional_bundle(dir, "state_providers")?.unwrap_or_default(), + }), _ => None, }; - let structures = match fs::read_to_string(dir.join("blocks/structure-blocks.json")) { - Ok(json) => Some(Arc::new(super::structure::StructureResources::new( - registry, - super::structure::StateTransforms::from_json(&json, registry.len())?, - &dir.join("structure"), - read_optional_bundle(dir, "processor_lists")?.unwrap_or_default(), - &block_tags, - ))), - Err(_) => None, - }; + let structures = joined(structures)?; let structure_resources = match ( read_optional_bundle(dir, "structures")?, read_optional_bundle(dir, "structure_sets")?, @@ -435,11 +463,7 @@ impl GeneratorData { }), _ => None, }; - let behaviours = dir - .parent() - .filter(|root| root.join("block-behaviours.json").exists()) - .and_then(|root| crate::level::Behaviours::load(registry, root).ok()) - .map(Arc::new); + let behaviours = joined(behaviours); Ok(Arc::new(Self { behaviours, structure_resources, @@ -594,6 +618,15 @@ impl ProtoChunk { pub fn is_decorated(&self) -> bool { self.decorated } + /// The structure starts this chunk holds itself (`structures.starts`). + pub fn own_starts(&self) -> Vec> { + let pos = self.position(); + self.structure_starts + .iter() + .filter(|s| s.chunk == pos) + .cloned() + .collect() + } /// Also restores a saved proto chunk that was decorated before it was saved. pub fn set_decorated(&mut self) { self.decorated = true; diff --git a/crates/horizon-world/src/worldgen/generator_blocks.rs b/crates/horizon-world/src/worldgen/generator_blocks.rs index ed1182f36..7498b9e66 100644 --- a/crates/horizon-world/src/worldgen/generator_blocks.rs +++ b/crates/horizon-world/src/worldgen/generator_blocks.rs @@ -108,15 +108,26 @@ impl BlockBehavior { let json = |name: &str| -> Result { Ok(serde_json::from_str(&fs::read_to_string(dir.join(name))?)?) }; - let collisions = - CachedCollisionRegistry::from_table(read("cached-collision.nbt")?.table("collision")?)?; - let properties = BlockPropertiesRegistry::from_table( - read("block-properties.nbt")?.table("block property")?, - &collisions, - )?; - let support = Arc::new(BlockSupportRegistry::from_table( - read("cached-support.nbt")?.table("support")?, - )?); + // The support table reads on its own thread while the collision and + // property tables (which need the collisions) read on this one. + let (collisions, properties, support) = std::thread::scope(|scope| { + let support = scope.spawn(|| -> Result<_, GeneratorError> { + Ok(BlockSupportRegistry::from_table( + read("cached-support.nbt")?.table("support")?, + )?) + }); + let collisions = CachedCollisionRegistry::from_table( + read("cached-collision.nbt")?.table("collision")?, + )?; + let properties = BlockPropertiesRegistry::from_table( + read("block-properties.nbt")?.table("block property")?, + &collisions, + )?; + let support = support + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic))?; + Ok::<_, GeneratorError>((collisions, properties, Arc::new(support))) + })?; let collision = Arc::new(BlockCollisionRegistry::new(Arc::new(collisions))?); let rules = json("survival-rules.json")? .as_object() diff --git a/crates/horizon-world/src/worldgen/structure.rs b/crates/horizon-world/src/worldgen/structure.rs index e0ac4d9e8..c998cc3be 100644 --- a/crates/horizon-world/src/worldgen/structure.rs +++ b/crates/horizon-world/src/worldgen/structure.rs @@ -79,6 +79,15 @@ impl Rotation { pub fn get_rotated(self, other: Self) -> Self { Self::ALL[(self as usize + other as usize) % 4] } + /// `Rotation.name()`, as structure pieces save it. + pub fn saved_name(self) -> &'static str { + match self { + Self::None => "NONE", + Self::Clockwise90 => "CLOCKWISE_90", + Self::Clockwise180 => "CLOCKWISE_180", + Self::Counterclockwise90 => "COUNTERCLOCKWISE_90", + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Mirror { @@ -86,6 +95,16 @@ pub enum Mirror { LeftRight, FrontBack, } +impl Mirror { + /// `Mirror.name()`, as structure pieces save it. + pub fn saved_name(self) -> &'static str { + match self { + Self::None => "NONE", + Self::LeftRight => "LEFT_RIGHT", + Self::FrontBack => "FRONT_BACK", + } + } +} /// Inclusive integer box (vanilla BoundingBox). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] diff --git a/crates/horizon-world/src/worldgen/structure/end_city.rs b/crates/horizon-world/src/worldgen/structure/end_city.rs index 225f810f5..b440fc832 100644 --- a/crates/horizon-world/src/worldgen/structure/end_city.rs +++ b/crates/horizon-world/src/worldgen/structure/end_city.rs @@ -381,19 +381,22 @@ pub fn pieces( } else { vec!["minecraft:structure_block".into(), "minecraft:air".into()] }; - EndCityPiece(TemplatePiece::new( - "minecraft:ecp", - &format!("minecraft:end_city/{}", d.name), - d.template, - d.position, - d.rotation, - Mirror::None, - [0; 3], - Arc::new(ProcessorList::default()), - true, - ignore, - TemplateHandler::Plain, - )) + EndCityPiece( + TemplatePiece::new( + "minecraft:ecp", + &format!("minecraft:end_city/{}", d.name), + d.template, + d.position, + d.rotation, + Mirror::None, + [0; 3], + Arc::new(ProcessorList::default()), + true, + ignore, + TemplateHandler::Plain, + ), + d.depth, + ) }) .collect(), ) @@ -401,11 +404,21 @@ pub fn pieces( /// EndCityPieces.EndCityPiece. #[derive(Debug)] -pub struct EndCityPiece(TemplatePiece); +/// The template piece and its genDepth (the collision tag of its batch). +pub struct EndCityPiece(TemplatePiece, i32); impl LegacyPiece for EndCityPiece { fn bounding_box(&self) -> BoundingBox { self.0.bounding_box } + /// `EndCityPiece`: the template piece's tag, `OW` for the pieces that + /// overwrite air (only structure blocks ignored). + fn save(&self) -> Option { + let mut tag = self.0.saved(); + tag.insert("GD".into(), crate::nbt::Tag::Int(self.1)); + let overwrite = self.0.ignore.len() == 1; + tag.insert("OW".into(), crate::nbt::Tag::Byte(i8::from(overwrite))); + Some(tag) + } fn type_name(&self) -> &'static str { "minecraft:ecp" } diff --git a/crates/horizon-world/src/worldgen/structure/enhanced_terrain.rs b/crates/horizon-world/src/worldgen/structure/enhanced_terrain.rs index 24c3b9a82..04f14e969 100644 --- a/crates/horizon-world/src/worldgen/structure/enhanced_terrain.rs +++ b/crates/horizon-world/src/worldgen/structure/enhanced_terrain.rs @@ -294,6 +294,8 @@ mod tests { source_x: junction[0], source_ground_y: junction[1], source_z: junction[2], + delta_y: 0, + dest_projection: Projection::Rigid, }], waterlogging: false, }) @@ -303,6 +305,7 @@ mod tests { Arc::new(PoolElement::Single { template: Arc::new(StructureTemplate::empty()), processors: Default::default(), + processors_source: serde_json::Value::Null, legacy: false, projection, waterlogging: None, diff --git a/crates/horizon-world/src/worldgen/structure/fortress.rs b/crates/horizon-world/src/worldgen/structure/fortress.rs index 4d3704413..521dac872 100644 --- a/crates/horizon-world/src/worldgen/structure/fortress.rs +++ b/crates/horizon-world/src/worldgen/structure/fortress.rs @@ -871,6 +871,29 @@ impl LegacyPiece for FortressPiece { fn bounding_box(&self) -> BoundingBox { self.frame.bounding_box } + fn save(&self) -> Option { + use crate::nbt::Tag; + let mut tag = super::legacy::piece_tag( + self.type_name(), + self.frame.bounding_box, + self.frame.orientation, + self.depth, + ); + let flag = Tag::Byte(i8::from(*self.flag.lock().unwrap())); + match self.kind { + Kind::BridgeEndFiller => { + tag.insert("Seed".into(), Tag::Int(self.seed)); + } + Kind::SmallCorridorLeftTurn | Kind::SmallCorridorRightTurn => { + tag.insert("Chest".into(), flag); + } + Kind::MonsterThrone => { + tag.insert("Mob".into(), flag); + } + _ => {} + } + Some(tag) + } fn type_name(&self) -> &'static str { match self.kind { // StartPiece is built through the BridgeCrossing constructor. diff --git a/crates/horizon-world/src/worldgen/structure/jigsaw.rs b/crates/horizon-world/src/worldgen/structure/jigsaw.rs index a6cf7a22a..6fd935024 100644 --- a/crates/horizon-world/src/worldgen/structure/jigsaw.rs +++ b/crates/horizon-world/src/worldgen/structure/jigsaw.rs @@ -5,6 +5,7 @@ use super::{ }; use crate::{ heightmap::HeightmapType, + nbt::{Compound, Tag}, worldgen::{ DecodeError, block_feature::FeatureBlockBindings, @@ -30,12 +31,24 @@ pub enum Projection { Rigid, TerrainMatching, } +impl Projection { + /// `StructureTemplatePool.Projection.getSerializedName`. + pub fn saved_name(self) -> &'static str { + match self { + Self::Rigid => "rigid", + Self::TerrainMatching => "terrain_matching", + } + } +} #[derive(Clone, Debug)] pub enum PoolElement { Empty, Single { template: Arc, processors: ProcessorList, + /// The element's `processors` as written: a list id or an inline list, + /// saved back as it was. + processors_source: Value, legacy: bool, projection: Projection, waterlogging: Option, @@ -136,6 +149,7 @@ impl PoolElement { resources, bindings, )?, + processors_source: value["processors"].clone(), legacy: kind == "minecraft:legacy_single_pool_element", projection: projection()?, waterlogging: match value @@ -178,6 +192,64 @@ impl PoolElement { _ => return Err(DecodeError::Field("pool element type")), }) } + /// `StructurePoolElement.CODEC`'s encoding, as a jigsaw piece saves it. + pub fn save(&self) -> Tag { + let text = |s: &str| Tag::String(s.into()); + match self { + Self::Empty => Tag::compound([("element_type", text("minecraft:empty_pool_element"))]), + Self::Single { + template, + processors_source, + legacy, + projection, + waterlogging, + .. + } => { + let kind = if *legacy { + "minecraft:legacy_single_pool_element" + } else { + "minecraft:single_pool_element" + }; + let mut entries = vec![ + ("element_type", text(kind)), + ("location", text(&template.location)), + ( + "processors", + super::processor::json_to_nbt(processors_source), + ), + ("projection", text(projection.saved_name())), + ]; + if let Some(apply) = waterlogging { + let settings = if *apply { + "apply_waterlogging" + } else { + "ignore_waterlogging" + }; + entries.push(("override_liquid_settings", text(settings))); + } + Tag::compound(entries) + } + Self::Feature { + feature, + projection, + } => Tag::compound([ + ("element_type", text("minecraft:feature_pool_element")), + ("feature", text(feature)), + ("projection", text(projection.saved_name())), + ]), + Self::List { + elements, + projection, + } => Tag::compound([ + ("element_type", text("minecraft:list_pool_element")), + ( + "elements", + Tag::List(elements.iter().map(Self::save).collect()), + ), + ("projection", text(projection.saved_name())), + ]), + } + } pub fn projection(&self) -> Projection { match self { Self::Empty => Projection::TerrainMatching, @@ -466,6 +538,9 @@ pub struct Junction { pub source_x: i32, pub source_ground_y: i32, pub source_z: i32, + /// Saved only (`JigsawJunction.deltaY`, `destProjection`). + pub delta_y: i32, + pub dest_projection: Projection, } #[derive(Debug)] pub struct PoolPiece { @@ -517,6 +592,12 @@ impl PoolPiece { source_x: int(j, "source_x")?, source_ground_y: int(j, "source_ground_y")?, source_z: int(j, "source_z")?, + delta_y: int(j, "delta_y")?, + dest_projection: match j["dest_proj"].as_str() { + Some("rigid") => Projection::Rigid, + Some("terrain_matching") => Projection::TerrainMatching, + _ => return Err(DecodeError::Field("dest_proj")), + }, }) }) .collect::>()?; @@ -535,6 +616,45 @@ impl PoolPiece { waterlogging: v["liquid_settings"].as_str() != Some("ignore_waterlogging"), }) } + /// `PoolElementStructurePiece`'s tag (always genDepth 0, no orientation). + pub fn save(&self) -> Compound { + let mut tag = super::legacy::piece_tag("minecraft:jigsaw", self.bounding_box, None, 0); + let junctions = self + .junctions + .iter() + .map(|j| { + Tag::compound([ + ("source_x", Tag::Int(j.source_x)), + ("source_ground_y", Tag::Int(j.source_ground_y)), + ("source_z", Tag::Int(j.source_z)), + ("delta_y", Tag::Int(j.delta_y)), + ( + "dest_proj", + Tag::String(j.dest_projection.saved_name().into()), + ), + ]) + }) + .collect(); + for (key, value) in [ + ("PosX", Tag::Int(self.position[0])), + ("PosY", Tag::Int(self.position[1])), + ("PosZ", Tag::Int(self.position[2])), + ("ground_level_delta", Tag::Int(self.ground_level_delta)), + ("pool_element", self.element.save()), + ("rotation", Tag::String(self.rotation.saved_name().into())), + ("junctions", Tag::List(junctions)), + ] { + tag.insert(key.into(), value); + } + // LiquidSettings.APPLY_WATERLOGGING, the default, is not written. + if !self.waterlogging { + tag.insert( + "liquid_settings".into(), + Tag::String("ignore_waterlogging".into()), + ); + } + tag + } pub(super) fn moved(&mut self, dy: i32) { self.position[1] += dy; self.bounding_box = self.bounding_box.moved([0, dy, 0]); @@ -1204,6 +1324,8 @@ impl Placer<'_, '_> { source_x: target_pos[0], source_ground_y: junction_y - local_y + source_ground, source_z: target_pos[2], + delta_y, + dest_projection: target.projection(), }); self.pieces.push(PoolPiece { element: target.clone(), @@ -1215,6 +1337,12 @@ impl Placer<'_, '_> { source_x: jigsaw.pos[0], source_ground_y: junction_y - target_local_y + target_ground, source_z: jigsaw.pos[2], + delta_y: -delta_y, + dest_projection: if source_rigid { + Projection::Rigid + } else { + Projection::TerrainMatching + }, }], waterlogging: self.config.waterlogging, }); diff --git a/crates/horizon-world/src/worldgen/structure/legacy.rs b/crates/horizon-world/src/worldgen/structure/legacy.rs index f558081ee..785b5fb2e 100644 --- a/crates/horizon-world/src/worldgen/structure/legacy.rs +++ b/crates/horizon-world/src/worldgen/structure/legacy.rs @@ -282,6 +282,11 @@ pub trait LegacyPiece: std::fmt::Debug + Send + Sync { fn bounding_box(&self) -> BoundingBox; /// The vanilla StructurePieceType id, for diagnostics and comparisons. fn type_name(&self) -> &'static str; + /// `StructurePiece.createTag`; None while Horizon cannot save this type, + /// and then its start is not saved. + fn save(&self) -> Option { + None + } fn post_process(&self, context: &mut PieceContext<'_>) -> Result<()>; /// Structure.afterPlace, run on the first piece once per chunk after all /// pieces were placed, with every piece's current box. @@ -290,6 +295,31 @@ pub trait LegacyPiece: std::fmt::Debug + Send + Sync { } } +/// `StructurePiece.createTag`'s own fields: the type, `BB`, `O` (the +/// orientation's 2D data value, -1 for none) and `GD` (genDepth). +pub fn piece_tag( + id: &str, + b: BoundingBox, + orientation: Option, + gen_depth: i32, +) -> Compound { + let o = orientation.map_or(-1, crate::level::data_2d); + [ + ("id", Tag::String(id.into())), + ( + "BB", + Tag::IntArray(vec![ + b.min[0], b.min[1], b.min[2], b.max[0], b.max[1], b.max[2], + ]), + ), + ("O", Tag::Int(o)), + ("GD", Tag::Int(gen_depth)), + ] + .into_iter() + .map(|(k, v)| (JavaString::from(k), v)) + .collect() +} + /// StructurePiece's orientation transform and block helpers. #[derive(Clone, Copy, Debug)] pub struct PieceFrame { diff --git a/crates/horizon-world/src/worldgen/structure/mineshaft.rs b/crates/horizon-world/src/worldgen/structure/mineshaft.rs index 1173c03bf..0c2d32c08 100644 --- a/crates/horizon-world/src/worldgen/structure/mineshaft.rs +++ b/crates/horizon-world/src/worldgen/structure/mineshaft.rs @@ -732,6 +732,57 @@ impl LegacyPiece for MineshaftPiece { fn bounding_box(&self) -> BoundingBox { self.frame.bounding_box } + fn save(&self) -> Option { + use crate::nbt::Tag; + let flag = |b: bool| Tag::Byte(i8::from(b)); + let mut tag = super::legacy::piece_tag( + self.type_name(), + self.frame.bounding_box, + self.frame.orientation, + self.depth, + ); + // MineshaftStructure.Type's ordinal: NORMAL, MESA. + let mesa = self.wood.log == MESA.log; + let mut entries = vec![("MST", Tag::Int(i32::from(mesa)))]; + match &self.kind { + Kind::Room(entrances) => entries.push(( + "Entrances", + Tag::List( + entrances + .iter() + .map(|b| { + Tag::IntArray(vec![ + b.min[0], b.min[1], b.min[2], b.max[0], b.max[1], b.max[2], + ]) + }) + .collect(), + ), + )), + Kind::Corridor { + rails, + spider, + sections, + placed_spider, + } => entries.extend([ + ("hr", flag(*rails)), + ("sc", flag(*spider)), + ("hps", flag(*placed_spider.lock().unwrap())), + ("Num", Tag::Int(*sections)), + ]), + Kind::Crossing { + direction, + two_floored, + } => { + let d = crate::level::data_2d(*direction) as i8; + entries.extend([("tf", flag(*two_floored)), ("D", Tag::Byte(d))]); + } + Kind::Stairs => {} + } + for (key, value) in entries { + tag.insert(key.into(), value); + } + Some(tag) + } fn type_name(&self) -> &'static str { match self.kind { Kind::Room(_) => "minecraft:msroom", diff --git a/crates/horizon-world/src/worldgen/structure/monument.rs b/crates/horizon-world/src/worldgen/structure/monument.rs index 39959557e..ababac969 100644 --- a/crates/horizon-world/src/worldgen/structure/monument.rs +++ b/crates/horizon-world/src/worldgen/structure/monument.rs @@ -463,6 +463,16 @@ impl LegacyPiece for MonumentBuilding { fn type_name(&self) -> &'static str { "minecraft:omb" } + /// `MonumentBuilding`: the base fields only; vanilla builds its rooms + /// again after loading (`OceanMonumentStructure.regeneratePiecesAfterLoad`). + fn save(&self) -> Option { + Some(super::legacy::piece_tag( + "minecraft:omb", + self.frame.bounding_box, + self.frame.orientation, + 0, + )) + } fn post_process(&self, c: &mut PieceContext<'_>) -> Result<()> { let sea = c.world.sea_level()?; let water = c.state("water"); diff --git a/crates/horizon-world/src/worldgen/structure/nether_fossil.rs b/crates/horizon-world/src/worldgen/structure/nether_fossil.rs index 48e4de58a..b80b8c1a8 100644 --- a/crates/horizon-world/src/worldgen/structure/nether_fossil.rs +++ b/crates/horizon-world/src/worldgen/structure/nether_fossil.rs @@ -73,6 +73,9 @@ impl LegacyPiece for NetherFossilPiece { fn bounding_box(&self) -> BoundingBox { self.0.bounding_box } + fn save(&self) -> Option { + Some(self.0.saved()) + } fn type_name(&self) -> &'static str { "minecraft:nefos" } diff --git a/crates/horizon-world/src/worldgen/structure/ocean_ruin.rs b/crates/horizon-world/src/worldgen/structure/ocean_ruin.rs index 9d0ce8923..bf15dc372 100644 --- a/crates/horizon-world/src/worldgen/structure/ocean_ruin.rs +++ b/crates/horizon-world/src/worldgen/structure/ocean_ruin.rs @@ -325,6 +325,8 @@ pub fn post_process(piece: &TemplatePiece, c: &mut PieceContext<'_>) -> Result<( new_y = min_y + 1; } position[1] = new_y; + // OceanRuinPiece.postProcess keeps the height in templatePosition. + *piece.state.lock().unwrap() = Some(new_y); if let Some(markers) = piece.place_at(c, position, &[])? { for (marker, pos) in markers { if marker == "chest" { diff --git a/crates/horizon-world/src/worldgen/structure/piece_limited.rs b/crates/horizon-world/src/worldgen/structure/piece_limited.rs index 6977ebc88..8b3a6d7de 100644 --- a/crates/horizon-world/src/worldgen/structure/piece_limited.rs +++ b/crates/horizon-world/src/worldgen/structure/piece_limited.rs @@ -910,6 +910,8 @@ impl Assembler<'_> { source_x: slot.target_pos[0], source_ground_y: junction_y - local_y + source_ground, source_z: slot.target_pos[2], + delta_y, + dest_projection: target.projection(), }); slot.free.borrow_mut().removed.push(target_box); self.pieces.push(PoolPiece { @@ -922,6 +924,12 @@ impl Assembler<'_> { source_x: jigsaw.pos[0], source_ground_y: junction_y - target_local[1] + target_ground, source_z: jigsaw.pos[2], + delta_y: -delta_y, + dest_projection: if source_rigid { + Projection::Rigid + } else { + Projection::TerrainMatching + }, }], waterlogging: self.config.waterlogging, }); @@ -1022,6 +1030,7 @@ mod tests { let element = Arc::new(PoolElement::Single { template, processors: Default::default(), + processors_source: serde_json::Value::Null, legacy: false, projection: Projection::Rigid, waterlogging: None, @@ -1038,6 +1047,8 @@ mod tests { source_x: 1, source_ground_y: 2, source_z: 3, + delta_y: 0, + dest_projection: Projection::Rigid, }], waterlogging: true, }; diff --git a/crates/horizon-world/src/worldgen/structure/processor.rs b/crates/horizon-world/src/worldgen/structure/processor.rs index f6f5d4508..0665f785d 100644 --- a/crates/horizon-world/src/worldgen/structure/processor.rs +++ b/crates/horizon-world/src/worldgen/structure/processor.rs @@ -21,7 +21,7 @@ use horizon_core::{ use serde_json::Value; use std::{collections::BTreeSet, sync::Arc}; -mod lithostitched; +mod litho; mod pillars; mod sea_level; @@ -238,6 +238,23 @@ impl Modifier { ) } } +impl Modifier { + /// RuleBlockEntityModifier.apply: the block entity the output gets. + fn apply(&self, nbt: Option<&Compound>, random: &mut impl ProcessorRandom) -> Option { + match self { + Self::Passthrough => nbt.cloned(), + Self::Clear => Some(Compound::new()), + Self::AppendLoot(table) => { + let mut nbt = nbt.cloned().unwrap_or_default(); + nbt.insert("LootTable".into(), Tag::String(table.as_str().into())); + nbt.insert("LootTableSeed".into(), Tag::Long(random.long())); + Some(nbt) + } + Self::AppendStatic(data) => Some(edited(nbt, &[], data)), + Self::Edit { remove, data } => Some(edited(nbt, remove, data)), + } + } +} /// A block entity with `remove`'s keys taken out and `data` merged in /// (append_static is the one with nothing to remove). fn edited(nbt: Option<&Compound>, remove: &[String], data: &Compound) -> Compound { @@ -292,17 +309,21 @@ pub enum Processor { SpawnerMobs(Box), Pick(Box), CountCap(Box), - /// `horizon:block_swap` (Lithostitched's `block_swap`): blocks by name - /// become others, keeping the properties both have. + /// `lithostitched:block_swap`: blocks by name become others, keeping + /// the properties both have (a block the game lacks is left alone). BlockSwap(std::collections::BTreeMap), - /// `horizon:apply_random` (Lithostitched's `apply_random`): a processor - /// list drawn by weight runs on the block. - ApplyRandom(Box), - /// `horizon:condition` (Lithostitched's `condition`): `then` or `else` + /// `lithostitched:apply_random`: a processor list drawn by weight runs + /// on the block. + ApplyRandom(Box), + /// `lithostitched:reference`: the named lists run on the block in turn. + Reference(Vec), + /// `lithostitched:discard_input`: the world's block stays. + DiscardInput, + /// `lithostitched:condition`: `then` or `else` /// runs on the block, by a test of it. - Condition(Box), - /// `horizon:set_block` (Lithostitched's `set_block`). - SetBlock(Box), + Condition(Box), + /// `lithostitched:set_block`: the block from a state provider. + SetBlock(Box), /// `horizon:pillar` (Repurposed Structures' and Moog's pillars): a /// trigger block starts a column down to the ground. Pillar(Box), @@ -685,7 +706,7 @@ fn holder_set( } Ok(Arc::new(out)) } -fn json_to_nbt(value: &Value) -> Tag { +pub(super) fn json_to_nbt(value: &Value) -> Tag { match value { Value::Bool(b) => Tag::Byte(i8::from(*b)), Value::Number(n) => match n.as_i64() { @@ -786,7 +807,7 @@ impl Processor { })?)) } "horizon:pick" => Self::Pick(Box::new(Pick::decode(value, state)?)), - "horizon:block_swap" => Self::BlockSwap( + "lithostitched:block_swap" => Self::BlockSwap( value["blocks"] .as_object() .ok_or(DecodeError::Field("blocks"))? @@ -801,12 +822,16 @@ impl Processor { .collect::>()?, ), "horizon:count_cap" => Self::CountCap(Box::new(CountCap::decode(value, state)?)), - "horizon:apply_random" => Self::ApplyRandom(Box::new( - lithostitched::ApplyRandom::decode(value, resources, bindings)?, + "lithostitched:apply_random" => Self::ApplyRandom(Box::new( + litho::ApplyRandom::decode(value, resources, bindings)?, )), - "horizon:set_block" => { - Self::SetBlock(Box::new(lithostitched::SetBlock::decode(value, state)?)) + "lithostitched:set_block" => { + Self::SetBlock(Box::new(litho::SetBlock::decode(value, state)?)) } + "lithostitched:reference" => { + Self::Reference(litho::reference(value, resources, bindings)?) + } + "lithostitched:discard_input" => Self::DiscardInput, "horizon:pillar" => Self::Pillar(Box::new(pillars::Pillar::decode( value, resources, bindings, state, )?)), @@ -834,7 +859,7 @@ impl Processor { "horizon:keep_world_block" => { Self::KeepWorldBlock(Box::new(sea_level::KeepWorldBlock::decode(value)?)) } - "horizon:condition" => Self::Condition(Box::new(lithostitched::Conditional::decode( + "lithostitched:condition" => Self::Condition(Box::new(litho::Conditional::decode( value, resources, bindings, )?)), _ => return Err(DecodeError::Field("structure processor type")), @@ -1115,24 +1140,10 @@ fn process_block( if !position_ok { continue; } - let nbt = match &rule.modifier { - Modifier::Passthrough => info.nbt.clone(), - Modifier::Clear => Some(Compound::new()), - Modifier::AppendLoot(table) => { - let mut nbt = info.nbt.clone().unwrap_or_default(); - nbt.insert("LootTable".into(), Tag::String(table.as_str().into())); - nbt.insert("LootTableSeed".into(), Tag::Long(random.next_long())); - Some(nbt) - } - Modifier::AppendStatic(data) => Some(edited(info.nbt.as_ref(), &[], data)), - Modifier::Edit { remove, data } => { - Some(edited(info.nbt.as_ref(), remove, data)) - } - }; return Ok(Some(BlockInfo { pos: info.pos, state: rule.output, - nbt, + nbt: rule.modifier.apply(info.nbt.as_ref(), &mut random), })); } Some(info) @@ -1165,8 +1176,7 @@ fn process_block( false => Some(info), }, Processor::ApplyRandom(apply) => { - let seed = world.world_seed()?; - let mut random = apply.random.create(seed, position, reference, info.pos); + let mut random = apply.draws.random(world.world_seed()?, position, info.pos); match apply.pick(&mut random) { Some(list) => { return process_list( @@ -1176,21 +1186,34 @@ fn process_block( None => Some(info), } } + Processor::Reference(lists) => { + let mut current = info; + for list in lists { + match process_list( + list, world, resources, position, reference, relative, current, settings, + )? { + Some(next) => current = next, + None => return Ok(None), + } + } + Some(current) + } + Processor::DiscardInput => None, Processor::SetBlock(set) => { - let seed = world.world_seed()?; - let state = set.state(seed, position, reference, info.pos); - let state = match set.preserves() { + let mut random = set.draws.random(world.world_seed()?, position, info.pos); + let state = set.state(&mut random); + let state = match set.preserve { true => registry .state(state) .and_then(|s| with_properties_of(registry, &s.name, info.state)) .unwrap_or(state), false => state, }; - Some(BlockInfo { state, ..info }) + let nbt = set.modifier.apply(info.nbt.as_ref(), &mut random); + Some(BlockInfo { state, nbt, ..info }) } Processor::Condition(c) => { - let seed = world.world_seed()?; - let mut random = c.random.create(seed, position, reference, info.pos); + let mut random = c.draws.random(world.world_seed()?, position, info.pos); let location = world.block_state(info.pos)?; let passed = c .condition diff --git a/crates/horizon-world/src/worldgen/structure/processor/litho.rs b/crates/horizon-world/src/worldgen/structure/processor/litho.rs new file mode 100644 index 000000000..6ba006fd6 --- /dev/null +++ b/crates/horizon-world/src/worldgen/structure/processor/litho.rs @@ -0,0 +1,841 @@ +//! Lithostitched's processors (spec-lithostitched §5), read as its data +//! writes them: `lithostitched:block_swap` (in `processor.rs`), `reference`, +//! `apply_random`, `condition`, `set_block` and `discard_input`. +//! +//! The draws of `random_chance`, `apply_random` and `set_block` follow +//! Lithostitched's `random_mode` (per block or per piece), but its seeding is +//! not known (OPEN-L6): a positional random of the world seed, the block's or +//! the piece's position and the processor itself, so the shares match while +//! the blocks chosen do not. +use super::{BlockStateId, DecodeError, Modifier, ProcessorList, ProcessorRandom}; +use crate::worldgen::{block_feature::FeatureBlockBindings, structure::StructureResources}; +use horizon_core::{ + position::BlockPos, + random::{LegacyRandom, position_seed}, +}; +use serde_json::Value; +use std::collections::BTreeSet; + +/// `random_mode` (`condition`, `set_block`) or `mode` (`apply_random`). +#[derive(Clone, Copy, Debug)] +pub(super) struct Draws { + per_piece: bool, + /// The processor's own salt, so two processors on a block draw apart. + salt: i64, +} +impl Draws { + fn decode(mode: Option<&Value>, processor: &Value) -> Result { + let per_piece = match mode.and_then(Value::as_str) { + None | Some("per_block") => false, + Some("per_piece") => true, + _ => return Err(DecodeError::Field("random_mode")), + }; + // FNV-1a over the processor's JSON: the same in every run. + let salt = (processor.to_string().bytes()).fold(0xcbf2_9ce4_8422_2325_u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3) + }) as i64; + Ok(Self { per_piece, salt }) + } + /// The random for the block at `pos` of the piece at `piece`. + pub(super) fn random(self, world_seed: i64, piece: [i32; 3], pos: [i32; 3]) -> LegacyRandom { + let [x, y, z] = if self.per_piece { piece } else { pos }; + LegacyRandom::new(world_seed ^ self.salt ^ position_seed(BlockPos::new(x, y, z))) + } +} + +/// A processor or a list of them (`then`, `else`, `apply_random`'s data): +/// a list holder (`ProcessorList::decode`), or one processor object. +fn processors( + value: &Value, + resources: &StructureResources, + bindings: &FeatureBlockBindings<'_>, +) -> Result { + let one = value.get("processor_type").is_some(); + let holder = if one { + Value::Array(vec![value.clone()]) + } else { + value.clone() + }; + ProcessorList::decode(&holder.to_string(), resources, bindings) +} + +/// `reference`: the named lists, run in turn on the block. +pub(super) fn reference( + value: &Value, + resources: &StructureResources, + bindings: &FeatureBlockBindings<'_>, +) -> Result, DecodeError> { + let names = match &value["processor_lists"] { + Value::Array(a) => a.clone(), + other => vec![other.clone()], + }; + (names.iter()) + .map(|name| match name.as_str() { + Some(n) if n.starts_with('#') => Err(DecodeError::Field("processor list tag")), + Some(_) => ProcessorList::decode(&name.to_string(), resources, bindings), + None => Err(DecodeError::Field("processor_lists")), + }) + .collect() +} + +/// `apply_random`: one list drawn by weight runs on the block. +#[derive(Clone, Debug)] +pub struct ApplyRandom { + pub(super) draws: Draws, + lists: Vec<(i32, ProcessorList)>, + total: i32, +} +impl ApplyRandom { + pub(super) fn decode( + value: &Value, + resources: &StructureResources, + bindings: &FeatureBlockBindings<'_>, + ) -> Result { + let entries = value["processor_lists"] + .as_array() + .ok_or(DecodeError::Field("apply_random processor_lists"))?; + let lists = (entries.iter()) + .map(|e| match e.get("data") { + Some(data) => { + let weight = e["weight"] + .as_i64() + .unwrap_or(1) + .clamp(0, i64::from(i32::MAX)); + Ok((weight as i32, processors(data, resources, bindings)?)) + } + None => Ok((1, processors(e, resources, bindings)?)), + }) + .collect::, DecodeError>>()?; + let total = lists + .iter() + .map(|(w, _)| *w) + .fold(0i32, i32::saturating_add); + Ok(Self { + draws: Draws::decode(value.get("mode"), value)?, + lists, + total, + }) + } + /// The list drawn: one bounded draw over the total weight. + pub(super) fn pick(&self, random: &mut impl ProcessorRandom) -> Option<&ProcessorList> { + if self.total <= 0 { + return None; + } + let mut at = random.int(self.total); + for (weight, list) in &self.lists { + at -= weight; + if at < 0 { + return Some(list); + } + } + None + } +} + +/// Lithostitched's processor conditions. +#[derive(Clone, Debug)] +pub(super) enum Condition { + True, + AllOf(Vec), + AnyOf(Vec), + Not(Box), + Chance(f32), + /// `matching_blocks`: the template's block (`input`) or the world's + /// (`location`) is one of `blocks`, with `properties`. + Matching { + blocks: BTreeSet, + properties: Vec<(String, String)>, + location: bool, + }, +} +impl Condition { + /// One condition, or a list of them that must all pass (`if_true`). + pub(super) fn decode( + value: &Value, + bindings: &FeatureBlockBindings<'_>, + ) -> Result { + let list = |v: &Value| -> Result, DecodeError> { + (v.as_array().ok_or(DecodeError::Field("conditions"))?.iter()) + .map(|c| Self::decode(c, bindings)) + .collect() + }; + if value.is_array() { + return Ok(Self::AllOf(list(value)?)); + } + let kind = value["type"].as_str().unwrap_or_default(); + Ok(match crate::worldgen::tags::identifier(kind)?.as_str() { + "lithostitched:true" => Self::True, + "lithostitched:all_of" => Self::AllOf(list(&value["conditions"])?), + "lithostitched:any_of" => Self::AnyOf(list(&value["conditions"])?), + "lithostitched:not" => { + Self::Not(Box::new(Self::decode(&value["condition"], bindings)?)) + } + "lithostitched:random_chance" => Self::Chance( + value["chance"] + .as_f64() + .ok_or(DecodeError::Field("random_chance chance"))? as f32, + ), + "lithostitched:matching_blocks" => Self::Matching { + blocks: blocks(&value["blocks"], bindings)?, + properties: (value + .get("properties") + .and_then(Value::as_object) + .into_iter()) + .flatten() + .map(|(k, v)| { + ( + k.clone(), + v.as_str().map_or_else(|| v.to_string(), str::to_owned), + ) + }) + .collect(), + location: match value.get("match_type").and_then(Value::as_str) { + None | Some("input") => false, + Some("location") => true, + _ => return Err(DecodeError::Field("matching_blocks match_type")), + }, + }, + _ => return Err(DecodeError::Field("lithostitched condition type")), + }) + } + pub(super) fn test( + &self, + input: BlockStateId, + location: BlockStateId, + registry: &crate::registry::BlockRegistry, + random: &mut impl ProcessorRandom, + ) -> bool { + match self { + Self::True => true, + Self::AllOf(all) => all + .iter() + .all(|c| c.test(input, location, registry, random)), + Self::AnyOf(any) => any + .iter() + .any(|c| c.test(input, location, registry, random)), + Self::Not(c) => !c.test(input, location, registry, random), + Self::Chance(chance) => random.float() < *chance, + Self::Matching { + blocks, + properties, + location: at_location, + } => { + let Some(state) = registry.state(if *at_location { location } else { input }) + else { + return false; + }; + blocks.contains(&state.name) + && (properties.iter()).all(|(k, v)| state.properties.get(k) == Some(v)) + } + } + } +} +/// A block set (an id, a list, `#tag`) by block name. Blocks the game does +/// not have are left out: no template block can be one. +fn blocks( + value: &Value, + bindings: &FeatureBlockBindings<'_>, +) -> Result, DecodeError> { + let names: Vec<&str> = match value { + Value::String(s) => vec![s.as_str()], + Value::Array(a) => a.iter().filter_map(Value::as_str).collect(), + _ => return Err(DecodeError::Field("matching_blocks blocks")), + }; + let registry = bindings.registry(); + let mut out = BTreeSet::new(); + for name in names { + match name.strip_prefix('#') { + Some(tag) => { + let tag = crate::worldgen::tags::identifier(tag)?; + let states = bindings.tags.get(&tag).into_iter().flat_map(|s| s.iter()); + out.extend(states.filter_map(|s| Some(registry.state(*s)?.name.clone()))); + } + None => { + out.insert(crate::worldgen::tags::identifier(name)?.to_string()); + } + } + } + Ok(out) +} + +/// `condition`: `then` when the conditions pass, else `else` (none: the +/// block as it is). +#[derive(Clone, Debug)] +pub struct Conditional { + pub(super) draws: Draws, + pub(super) condition: Condition, + pub(super) then: ProcessorList, + pub(super) otherwise: ProcessorList, +} +impl Conditional { + pub(super) fn decode( + value: &Value, + resources: &StructureResources, + bindings: &FeatureBlockBindings<'_>, + ) -> Result { + let branch = |key| match value.get(key) { + Some(v) => processors(v, resources, bindings), + None => Ok(ProcessorList(Vec::new())), + }; + Ok(Self { + draws: Draws::decode(value.get("random_mode"), value)?, + condition: Condition::decode(&value["if_true"], bindings)?, + then: branch("then")?, + otherwise: branch("else")?, + }) + } +} + +/// `set_block`: the block from a state provider (simple or weighted). +#[derive(Clone, Debug)] +pub struct SetBlock { + pub(super) draws: Draws, + states: Vec<(i32, BlockStateId)>, + total: i32, + /// `preserve_state` (true by default): the template block's properties + /// the new block has too are kept. + pub(super) preserve: bool, + pub(super) modifier: Modifier, +} +impl SetBlock { + pub(super) fn decode( + value: &Value, + state: impl Fn(&Value) -> Result, + ) -> Result { + let provider = &value["state_provider"]; + let kind = provider["type"].as_str().unwrap_or_default(); + let states = match crate::worldgen::tags::identifier(kind)?.as_str() { + "minecraft:simple_state_provider" => vec![(1, state(&provider["state"])?)], + "minecraft:weighted_state_provider" => (provider["entries"].as_array().into_iter()) + .flatten() + .map(|e| { + Ok(( + e["weight"].as_i64().unwrap_or(1).clamp(0, 1 << 30) as i32, + state(&e["data"])?, + )) + }) + .collect::>()?, + _ => return Err(DecodeError::Field("set_block state_provider")), + }; + let total = states.iter().map(|(w, _)| *w).sum(); + if total <= 0 { + return Err(DecodeError::Field("set_block state_provider")); + } + Ok(Self { + draws: Draws::decode(value.get("random_mode"), value)?, + states, + total, + preserve: value + .get("preserve_state") + .and_then(Value::as_bool) + .unwrap_or(true), + modifier: Modifier::decode(value.get("block_entity_modifier"))?, + }) + } + /// The provider's state: WeightedList.getRandom, one bounded draw. + pub(super) fn state(&self, random: &mut impl ProcessorRandom) -> BlockStateId { + let mut at = random.int(self.total); + for (weight, state) in &self.states { + at -= weight; + if at < 0 { + return *state; + } + } + self.states[0].1 + } +} + +#[cfg(test)] +mod tests { + use super::super::{BlockInfo, PlaceSettings, ProcessorList, process_list}; + use crate::{ + heightmap::HeightmapType, + nbt::{JavaString, Tag}, + registry::{BlockRegistry, BlockStateId}, + worldgen::{ + anchor::WorldGenerationContext, + block_feature::FeatureBlockBindings, + placement::{PlacementContext, PlacementQueryError}, + structure::{ + Rotation, StateTransforms, StructureResources, template::parse_state_string, + }, + }, + }; + use serde_json::{Value, json}; + use std::collections::{BTreeMap, BTreeSet}; + + /// Every combination of `properties`' values, the first one the default. + fn states(name: &str, properties: &[(&str, &[&str])]) -> Vec { + let mut out = vec![serde_json::Map::new()]; + for (key, values) in properties { + out = (out.iter()) + .flat_map(|o| { + values.iter().map(move |v| { + let mut o = o.clone(); + o.insert((*key).into(), (*v).into()); + o + }) + }) + .collect(); + } + (out.into_iter()) + .map(|p| json!([name, name.ends_with("air"), false, "", {"id": name, "properties": p}])) + .collect() + } + + /// A registry of the blocks CTOV's lists and the probe pack use. + fn registry() -> &'static BlockRegistry { + let ages: Vec = (0..8).map(|a| a.to_string()).collect(); + let ages: Vec<&str> = ages.iter().map(String::as_str).collect(); + let levels: Vec = (0..16).map(|a| a.to_string()).collect(); + let levels: Vec<&str> = levels.iter().map(String::as_str).collect(); + let water = [("level", &levels[..])]; + let stairs: &[(&str, &[&str])] = + &[("facing", &["north", "east"]), ("half", &["bottom", "top"])]; + let blocks: Vec<(&str, &[(&str, &[&str])])> = vec![ + ("minecraft:air", &[]), + ("minecraft:cave_air", &[]), + ("minecraft:void_air", &[]), + ("minecraft:lava", &water), + ("minecraft:cobblestone", &[]), + ("minecraft:blackstone", &[]), + ("minecraft:deepslate", &[]), + ("minecraft:water", &water), + ("minecraft:spruce_planks", &[]), + ("minecraft:chest", &[]), + ("minecraft:oak_log", &[("axis", &["y", "x"])]), + ("minecraft:stripped_cherry_log", &[("axis", &["y", "x"])]), + ("minecraft:oak_stairs", stairs), + ("minecraft:warped_stairs", stairs), + ]; + let wheat = [("age", &ages[..])]; + let beetroots = [("age", &ages[..4])]; + let mut rows = Vec::new(); + let mut defaults = BTreeMap::new(); + let crops: [(&str, &[(&str, &[&str])]); 3] = [ + ("minecraft:wheat", &wheat), + ("minecraft:carrots", &wheat), + ("minecraft:beetroots", &beetroots), + ]; + for (name, properties) in blocks.into_iter().chain(crops) { + defaults.insert(name, rows.len()); + rows.extend(states(name, properties)); + } + let facts = json!({"states": rows, "defaults": defaults}); + let registry = BlockRegistry::from_facts_json(facts.to_string().as_bytes()).unwrap(); + Box::leak(Box::new(registry)) + } + + /// The world a piece is placed in: one block everywhere. + struct World { + seed: i64, + block: BlockStateId, + } + impl PlacementContext for World { + fn world_seed(&mut self) -> Result { + Ok(self.seed) + } + fn block_state(&mut self, _: [i32; 3]) -> Result { + Ok(self.block) + } + fn generation(&self) -> WorldGenerationContext { + WorldGenerationContext::new(-64, 384, 63) + } + fn min_y(&mut self) -> Result { + Ok(-64) + } + fn height(&mut self, _: HeightmapType, _: i32, _: i32) -> Result { + Err(PlacementQueryError::Unavailable) + } + } + + struct Lists { + registry: &'static BlockRegistry, + resources: StructureResources, + } + impl Lists { + fn new(lists: &[(&str, Value)]) -> Self { + let registry = registry(); + let lists = (lists.iter()) + .map(|(id, list)| ((*id).to_owned(), list.to_string())) + .collect(); + let resources = StructureResources::new( + registry, + StateTransforms::default(), + std::path::Path::new("/nonexistent"), + lists, + &BTreeMap::new(), + ); + Self { + registry, + resources, + } + } + fn id(&self, state: &str) -> BlockStateId { + parse_state_string(self.registry, state).unwrap() + } + fn decode(&self, processors: Value) -> Result { + let bindings = FeatureBlockBindings::new(self.registry, BTreeSet::new()).unwrap(); + ProcessorList::decode(&processors.to_string(), &self.resources, &bindings) + } + /// The block the list makes of template block `state` at `pos` of the + /// piece at `piece`, in a world of `world`: None when it is not placed. + fn run( + &self, + list: &ProcessorList, + world: (i64, &str), + piece: [i32; 3], + pos: [i32; 3], + state: &str, + ) -> Option { + let mut world = World { + seed: world.0, + block: self.id(world.1), + }; + let info = BlockInfo { + pos, + state: self.id(state), + nbt: None, + }; + let mut settings = PlaceSettings::new(Rotation::None); + process_list( + list, + &mut world, + &self.resources, + piece, + piece, + [0; 3], + info, + &mut settings, + ) + .unwrap() + } + fn name(&self, info: Option) -> String { + match info { + None => "none".into(), + Some(info) => { + let s = self.registry.state(info.state).unwrap(); + let props: Vec = s + .properties + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + match props.is_empty() { + true => s.name.clone(), + false => format!("{}[{}]", s.name, props.join(",")), + } + } + } + } + /// `name(run(...))` for one template block in an air world of seed 0. + fn out(&self, list: &ProcessorList, state: &str) -> String { + self.name(self.run(list, (0, "minecraft:air"), [0; 3], [0; 3], state)) + } + } + + fn swap(blocks: Value) -> Value { + json!({"processor_type": "lithostitched:block_swap", "blocks": blocks}) + } + fn set_block(provider: Value) -> Value { + json!({"processor_type": "lithostitched:set_block", "state_provider": provider}) + } + fn simple(name: &str) -> Value { + json!({"type": "minecraft:simple_state_provider", "state": {"id": name}}) + } + + #[test] + fn block_swap_keeps_the_properties_the_new_block_can_have() { + let t = Lists::new(&[]); + let list = t + .decode(json!([swap(json!({ + "minecraft:wheat": "minecraft:carrots", + "oak_stairs": "minecraft:warped_stairs", + "minecraft:cobblestone": "natures_spirit:missing"}))])) + .unwrap(); + assert_eq!( + t.out(&list, "minecraft:wheat[age=7]"), + "minecraft:carrots[age=7]" + ); + assert_eq!( + t.out(&list, "minecraft:oak_stairs[facing=east,half=top]"), + "minecraft:warped_stairs[facing=east,half=top]" + ); + // A block the game lacks leaves the template's; others pass. + assert_eq!( + t.out(&list, "minecraft:cobblestone"), + "minecraft:cobblestone" + ); + assert_eq!( + t.out(&list, "minecraft:oak_log[axis=x]"), + "minecraft:oak_log[axis=x]" + ); + // Beetroots' age stops at 3: ripe wheat gives the default (spec-ctov §4). + let beet = t + .decode(json!([swap( + json!({"minecraft:wheat": "minecraft:beetroots"}) + )])) + .unwrap(); + assert_eq!( + t.out(&beet, "minecraft:wheat[age=7]"), + "minecraft:beetroots[age=0]" + ); + assert_eq!( + t.out(&beet, "minecraft:wheat[age=2]"), + "minecraft:beetroots[age=2]" + ); + } + + #[test] + fn references_run_the_named_lists_in_turn() { + let t = Lists::new(&[ + ( + "t:logs", + json!({"processors": [swap(json!({"minecraft:oak_log": "minecraft:stripped_cherry_log"}))]}), + ), + ( + "t:stone", + json!([swap( + json!({"minecraft:stripped_cherry_log": "minecraft:cobblestone"}) + )]), + ), + ( + "t:discard", + json!([{"processor_type": "lithostitched:discard_input"}]), + ), + ]); + let reference = |lists: Value| json!([{"processor_type": "lithostitched:reference", "processor_lists": lists}]); + let one = t.decode(reference(json!("t:logs"))).unwrap(); + assert_eq!( + t.out(&one, "minecraft:oak_log[axis=x]"), + "minecraft:stripped_cherry_log[axis=x]" + ); + let both = t.decode(reference(json!(["t:logs", "t:stone"]))).unwrap(); + assert_eq!(t.out(&both, "minecraft:oak_log"), "minecraft:cobblestone"); + let reversed = t.decode(reference(json!(["t:stone", "t:logs"]))).unwrap(); + assert_eq!( + t.out(&reversed, "minecraft:oak_log"), + "minecraft:stripped_cherry_log[axis=y]" + ); + // discard_input: the template block is not placed, and the lists after it do not run. + let discard = t.decode(reference(json!(["t:discard", "t:logs"]))).unwrap(); + assert_eq!(t.out(&discard, "minecraft:oak_log"), "none"); + // The game has no processor list tags; unknown lists are errors. + assert!(t.decode(reference(json!(["#t:palettes"]))).is_err()); + assert!(t.decode(reference(json!("t:missing"))).is_err()); + } + + /// The probe pack's nested condition (spec-lithostitched §7.3): template + /// cobblestone becomes blackstone or deepslate, half each. + #[test] + fn conditions_run_then_or_else() { + let t = Lists::new(&[]); + let inner = json!({"processor_type": "lithostitched:condition", "random_mode": "per_block", + "if_true": {"type": "lithostitched:random_chance", "chance": 0.5}, + "then": set_block(simple("minecraft:blackstone")), + "else": [set_block(simple("minecraft:deepslate"))]}); + let list = t + .decode(json!([{"processor_type": "lithostitched:condition", + "if_true": [{"type": "lithostitched:matching_blocks", "blocks": "minecraft:cobblestone"}], + "then": [inner]}])) + .unwrap(); + let mut counts = BTreeMap::::new(); + for i in 0..4000 { + let out = t.run( + &list, + (1234, "minecraft:air"), + [0; 3], + [i % 64, i / 64, 7], + "minecraft:cobblestone", + ); + *counts.entry(t.name(out)).or_default() += 1; + } + assert_eq!(counts.len(), 2, "{counts:?}"); + assert!( + (1850..2150).contains(&counts["minecraft:blackstone"]), + "{counts:?}" + ); + // Not cobblestone, and no `else`: the block as it is. + assert_eq!( + t.out(&list, "minecraft:oak_log"), + "minecraft:oak_log[axis=y]" + ); + + // `location` tests the world's block, with properties on the input. + let water = t + .decode( + json!([{"processor_type": "lithostitched:condition", "random_mode": "per_piece", + "if_true": {"type": "lithostitched:matching_blocks", "blocks": ["minecraft:water"], + "match_type": "location"}, + "then": set_block(simple("minecraft:spruce_planks"))}]), + ) + .unwrap(); + let at = |world| { + t.name(t.run( + &water, + (0, world), + [0; 3], + [1, 2, 3], + "minecraft:cobblestone", + )) + }; + assert_eq!(at("minecraft:water"), "minecraft:spruce_planks"); + assert_eq!(at("minecraft:air"), "minecraft:cobblestone"); + let ripe = t + .decode(json!([{"processor_type": "lithostitched:condition", + "if_true": {"type": "lithostitched:all_of", "conditions": [ + {"type": "lithostitched:true"}, + {"type": "lithostitched:matching_blocks", "blocks": "minecraft:wheat", "properties": {"age": "7"}}, + {"type": "lithostitched:not", "condition": {"type": "lithostitched:any_of", "conditions": [ + {"type": "lithostitched:random_chance", "chance": 0.0}, + {"type": "lithostitched:matching_blocks", "blocks": "minecraft:water", "match_type": "location"}]}}]}, + "then": swap(json!({"minecraft:wheat": "minecraft:carrots"}))}])) + .unwrap(); + assert_eq!( + t.out(&ripe, "minecraft:wheat[age=7]"), + "minecraft:carrots[age=7]" + ); + assert_eq!( + t.out(&ripe, "minecraft:wheat[age=6]"), + "minecraft:wheat[age=6]" + ); + let bad = json!([{"processor_type": "lithostitched:condition", "random_mode": "per_chunk", + "if_true": {"type": "lithostitched:true"}}]); + assert!(t.decode(bad).is_err()); + } + + /// `apply_random` (spec-lithostitched §7.3: crimson 1 : birch 3, one + /// choice per piece): here blackstone 1 : deepslate 3. + #[test] + fn apply_random_draws_per_piece_or_per_block() { + let t = Lists::new(&[( + "t:deepslate", + json!([swap( + json!({"minecraft:cobblestone": "minecraft:deepslate"}) + )]), + )]); + let random = |mode: &str| { + t.decode(json!([{"processor_type": "lithostitched:apply_random", "mode": mode, + "processor_lists": [ + {"weight": 1, "data": [swap(json!({"minecraft:cobblestone": "minecraft:blackstone"}))]}, + {"weight": 3, "data": "t:deepslate"}]}])) + .unwrap() + }; + let (per_piece, per_block) = (random("per_piece"), random("per_block")); + let outcomes = |list: &ProcessorList, seed: i64, piece: [i32; 3]| -> Vec { + (0..20) + .map(|i| { + let pos = [piece[0] + i, piece[1], piece[2] + 3 * i]; + t.name(t.run( + list, + (seed, "minecraft:air"), + piece, + pos, + "minecraft:cobblestone", + )) + }) + .collect() + }; + let mut blackstone_pieces = 0; + for p in 0..400 { + let piece = [16 * p, 70, -40 * p]; + let blocks = outcomes(&per_piece, 1234, piece); + assert!(blocks.iter().all(|b| *b == blocks[0]), "{blocks:?}"); + blackstone_pieces += u32::from(blocks[0] == "minecraft:blackstone"); + // The same piece and seed give the same blocks. + assert_eq!(outcomes(&per_piece, 1234, piece), blocks); + } + assert!( + (70..130).contains(&blackstone_pieces), + "{blackstone_pieces}" + ); + // Per block, one piece mixes both. + let mixed: BTreeSet = (0..10) + .flat_map(|p| outcomes(&per_block, 1234, [p, 0, 0])) + .collect(); + assert_eq!(mixed.len(), 2, "{mixed:?}"); + // A plain list: every entry weight 1. An entry list holding nothing is a no-op. + let plain = t + .decode( + json!([{"processor_type": "lithostitched:apply_random", "mode": "per_block", + "processor_lists": ["t:deepslate", []]}]), + ) + .unwrap(); + let seen: BTreeSet = (0..10) + .flat_map(|p| outcomes(&plain, 9, [p, 0, 0])) + .collect(); + assert_eq!( + seen, + BTreeSet::from(["minecraft:cobblestone".into(), "minecraft:deepslate".into()]) + ); + } + + #[test] + fn set_block_runs_its_provider() { + let t = Lists::new(&[]); + let stairs = "minecraft:oak_stairs[facing=east,half=top]"; + let warped = |preserve: Value| { + let mut p = set_block(simple("minecraft:warped_stairs")); + if !preserve.is_null() { + p["preserve_state"] = preserve; + } + t.decode(json!([p])).unwrap() + }; + // preserve_state (true by default) keeps the properties both blocks have. + assert_eq!( + t.out(&warped(Value::Null), stairs), + "minecraft:warped_stairs[facing=east,half=top]" + ); + assert_eq!( + t.out(&warped(json!(false)), stairs), + "minecraft:warped_stairs[facing=north,half=bottom]" + ); + + let weighted = t + .decode(json!([set_block( + json!({"type": "minecraft:weighted_state_provider", "entries": [ + {"weight": 1, "data": {"id": "minecraft:blackstone"}}, + {"weight": 3, "data": {"id": "minecraft:deepslate"}}]}) + )])) + .unwrap(); + let blackstone = (0..2000) + .filter(|i| { + let out = t.run( + &weighted, + (42, "minecraft:air"), + [0; 3], + [*i, 0, -*i], + "minecraft:cobblestone", + ); + t.name(out) == "minecraft:blackstone" + }) + .count(); + assert!((420..580).contains(&blackstone), "{blackstone}"); + + // block_entity_modifier gives the output its block entity. + let mut loot = set_block(simple("minecraft:chest")); + loot["block_entity_modifier"] = + json!({"type": "minecraft:append_loot", "loot_table": "t:chests/house"}); + let out = t + .run( + &t.decode(json!([loot])).unwrap(), + (0, "minecraft:air"), + [0; 3], + [0; 3], + "minecraft:air", + ) + .unwrap(); + let table = out + .nbt + .as_ref() + .and_then(|n| n.get(&JavaString::from("LootTable"))); + assert_eq!(table, Some(&Tag::String("t:chests/house".into()))); + + // Providers the game does not run are refused. + assert!( + t.decode(json!([set_block( + json!({"type": "minecraft:noise_provider"}) + )])) + .is_err() + ); + } +} diff --git a/crates/horizon-world/src/worldgen/structure/processor/lithostitched.rs b/crates/horizon-world/src/worldgen/structure/processor/lithostitched.rs deleted file mode 100644 index f0bb1c6ba..000000000 --- a/crates/horizon-world/src/worldgen/structure/processor/lithostitched.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! Lithostitched's processors that pick processors, carried as data -//! (`horizon:apply_random` and `horizon:condition`): one runs a processor -//! list drawn by weight, the other one list or another by a test of the -//! block. Both draw from Lithostitched's RandomSettings: a random seeded from -//! the world seed and a name, at the block, the piece or the structure. -//! `horizon:set_block` is its `set_block`, which the conditions mostly run. -use super::{Processor, ProcessorList}; -use crate::{ - registry::{BlockRegistry, BlockStateId}, - worldgen::{DecodeError, block_feature::FeatureBlockBindings, structure::StructureResources}, -}; -use horizon_core::{ - position::BlockPos, - random::{LegacyRandom, PositionalRandom, RandomSource}, -}; -use serde_json::Value; -use std::{collections::BTreeSet, sync::Arc}; - -/// RandomSettings: where the random is taken, and the name salting it. -#[derive(Clone, Debug, PartialEq)] -pub struct RandomSettings { - mode: Mode, - /// ResourceLocation.hashCode of the name (`lithostitched:default`). - salt: i32, -} -#[derive(Clone, Copy, Debug, PartialEq)] -enum Mode { - Block, - Piece, - Structure, -} -impl RandomSettings { - /// A mode alone, or `{"mode": ..., "name": ...}`; per block when unset. - fn decode(v: Option<&Value>) -> Result { - let mode = Mode::decode; - let (mode, name) = match v { - None => (Mode::Block, "lithostitched:default"), - Some(Value::String(m)) => (mode(Some(m))?, "lithostitched:default"), - Some(o) => ( - mode(o["mode"].as_str())?, - o["name"] - .as_str() - .ok_or(DecodeError::Field("random name"))?, - ), - }; - let (namespace, path) = name.split_once(':').unwrap_or(("minecraft", name)); - Ok(Self { - mode, - salt: java_hash(namespace) - .wrapping_mul(31) - .wrapping_add(java_hash(path)), - }) - } - /// RandomSource.create(seed + hash).forkPositional().at(the chosen - /// position): the piece's position, the structure's pivot or the block. - pub(super) fn create( - &self, - seed: i64, - piece: [i32; 3], - pivot: [i32; 3], - block: [i32; 3], - ) -> LegacyRandom { - let [x, y, z] = self.mode.select(piece, pivot, block); - LegacyRandom::new(seed.wrapping_add(i64::from(self.salt))) - .fork_positional() - .at(BlockPos::new(x, y, z)) - } -} -impl Mode { - fn decode(m: Option<&str>) -> Result { - match m { - None | Some("per_block") => Ok(Self::Block), - Some("per_piece") => Ok(Self::Piece), - Some("per_structure") => Ok(Self::Structure), - _ => Err(DecodeError::Field("random mode")), - } - } - fn select(self, piece: [i32; 3], pivot: [i32; 3], block: [i32; 3]) -> [i32; 3] { - match self { - Self::Block => block, - Self::Piece => piece, - Self::Structure => pivot, - } - } -} - -/// SetBlockProcessor with a simple or weighted state provider and no block -/// entity change: the block becomes the state, keeping the properties it -/// shares with the old one unless `preserve_state` is off. -#[derive(Clone, Debug)] -pub struct SetBlock { - /// The states and their weights; one state alone is a simple provider, - /// which draws nothing. - states: Vec<(BlockStateId, i32)>, - weighted: bool, - preserve: bool, - mode: Mode, -} -impl SetBlock { - pub(super) fn decode( - v: &Value, - state: impl Fn(&Value) -> Result, - ) -> Result { - let provider = &v["state_provider"]; - let (states, weighted) = match provider["type"].as_str() { - Some("minecraft:simple_state_provider") => { - (vec![(state(&provider["state"])?, 1)], false) - } - Some("minecraft:weighted_state_provider") => ( - provider["entries"] - .as_array() - .ok_or(DecodeError::Field("set_block entries"))? - .iter() - .map(|e| { - let weight = e["weight"] - .as_i64() - .ok_or(DecodeError::Field("set_block weight"))?; - Ok((state(&e["data"])?, weight as i32)) - }) - .collect::>()?, - true, - ), - _ => return Err(DecodeError::Field("set_block state_provider")), - }; - if states.is_empty() { - return Err(DecodeError::Field("set_block entries")); - } - Ok(Self { - states, - weighted, - preserve: v["preserve_state"].as_bool().unwrap_or(true), - mode: Mode::decode(v["random_mode"].as_str())?, - }) - } - /// The state to set, before `preserve`: drawn with - /// RandomSource.create(seed).forkPositional().at(the chosen position). - pub(super) fn state( - &self, - seed: i64, - piece: [i32; 3], - pivot: [i32; 3], - block: [i32; 3], - ) -> BlockStateId { - if !self.weighted { - return self.states[0].0; - } - let [x, y, z] = self.mode.select(piece, pivot, block); - let mut random = LegacyRandom::new(seed) - .fork_positional() - .at(BlockPos::new(x, y, z)); - let total: i32 = self.states.iter().map(|s| s.1).sum(); - let mut value = random.next_int_bound(total.max(1)).unwrap_or(0); - for (state, weight) in &self.states { - if value < *weight { - return *state; - } - value -= weight; - } - self.states[0].0 - } - pub(super) fn preserves(&self) -> bool { - self.preserve - } -} - -/// String.hashCode over UTF-16 units. -fn java_hash(s: &str) -> i32 { - s.encode_utf16() - .fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c))) -} - -fn list( - v: &Value, - resources: &StructureResources, - bindings: &FeatureBlockBindings<'_>, -) -> Result { - ProcessorList::decode(&v.to_string(), resources, bindings) -} - -/// ApplyRandomStructureProcessor: a weighted list of processor lists, one -/// drawn per block with `nextInt(total)`. -#[derive(Clone, Debug)] -pub struct ApplyRandom { - pub(super) random: RandomSettings, - lists: Vec<(ProcessorList, i32)>, - total: i32, -} -impl ApplyRandom { - pub(super) fn decode( - v: &Value, - resources: &StructureResources, - bindings: &FeatureBlockBindings<'_>, - ) -> Result { - let lists = v["processor_lists"] - .as_array() - .ok_or(DecodeError::Field("apply_random processor_lists"))? - .iter() - .map(|e| { - let weight = e["weight"] - .as_i64() - .ok_or(DecodeError::Field("apply_random weight"))?; - Ok((list(&e["data"], resources, bindings)?, weight as i32)) - }) - .collect::, DecodeError>>()?; - Ok(Self { - random: RandomSettings::decode(v.get("mode"))?, - total: lists.iter().map(|l| l.1).sum(), - lists, - }) - } - /// WeightedList.getRandom: none when the weights add up to 0. - pub(super) fn pick(&self, random: &mut impl RandomSource) -> Option<&ProcessorList> { - if self.total <= 0 { - return None; - } - let mut value = random.next_int_bound(self.total).unwrap_or(0); - for (list, weight) in &self.lists { - if value < *weight { - return Some(list); - } - value -= weight; - } - None - } -} - -/// ConditionProcessor: `then` when the condition holds for the block, else -/// `else`. -#[derive(Clone, Debug)] -pub struct Conditional { - pub(super) random: RandomSettings, - pub(super) condition: Condition, - pub(super) then: ProcessorList, - pub(super) otherwise: ProcessorList, -} -impl Conditional { - pub(super) fn decode( - v: &Value, - resources: &StructureResources, - bindings: &FeatureBlockBindings<'_>, - ) -> Result { - // A processor alone or a list of them. - let processors = |key: &str| -> Result { - match v.get(key) { - None => Ok(ProcessorList::default()), - Some(Value::Object(p)) if p.contains_key("processor_type") => Ok(ProcessorList( - vec![Processor::decode(&v[key], resources, bindings)?], - )), - Some(other) => list(other, resources, bindings), - } - }; - Ok(Self { - random: RandomSettings::decode(v.get("random_mode"))?, - condition: Condition::decode(&v["if_true"], bindings)?, - then: processors("then")?, - otherwise: processors("else")?, - }) - } -} - -/// Lithostitched's processor conditions the game runs. -#[derive(Clone, Debug)] -pub(super) enum Condition { - True, - AllOf(Vec), - AnyOf(Vec), - Not(Box), - RandomChance(f32), - /// The block placed (`input`) or the one in the world (`location`) is - /// one of these, with these property values. - MatchingBlocks { - blocks: Arc>, - properties: Vec<(String, String)>, - location: bool, - }, -} -impl Condition { - fn decode(v: &Value, bindings: &FeatureBlockBindings<'_>) -> Result { - let all = |a: &Value| -> Result, DecodeError> { - a.as_array() - .ok_or(DecodeError::Field("processor conditions"))? - .iter() - .map(|c| Self::decode(c, bindings)) - .collect() - }; - if v.is_array() { - return Ok(Self::AllOf(all(v)?)); - } - let kind = v["type"].as_str().unwrap_or_default(); - Ok(match kind.strip_prefix("lithostitched:").unwrap_or(kind) { - "true" => Self::True, - "all_of" => Self::AllOf(all(&v["conditions"])?), - "any_of" => Self::AnyOf(all(&v["conditions"])?), - "not" => Self::Not(Box::new(Self::decode(&v["condition"], bindings)?)), - "random_chance" => Self::RandomChance(super::float(v, "chance")?), - "matching_blocks" => Self::MatchingBlocks { - blocks: super::holder_set(&v["blocks"], bindings)?, - properties: match v.get("properties") { - None => Vec::new(), - Some(p) => p - .as_object() - .ok_or(DecodeError::Field("matching_blocks properties"))? - .iter() - .map(|(k, x)| { - let x = x - .as_str() - .ok_or(DecodeError::Field("matching_blocks property"))?; - Ok((k.clone(), x.to_owned())) - }) - .collect::>()?, - }, - location: match v["match_type"].as_str() { - None | Some("input") => false, - Some("location") => true, - _ => return Err(DecodeError::Field("matching_blocks match_type")), - }, - }, - _ => return Err(DecodeError::Field("processor condition type")), - }) - } - /// ProcessorCondition.test with the block placed and the one there. - pub(super) fn test( - &self, - input: BlockStateId, - location: BlockStateId, - registry: &BlockRegistry, - random: &mut impl RandomSource, - ) -> bool { - match self { - Self::True => true, - Self::AllOf(all) => all - .iter() - .all(|c| c.test(input, location, registry, random)), - Self::AnyOf(any) => any - .iter() - .any(|c| c.test(input, location, registry, random)), - Self::Not(c) => !c.test(input, location, registry, random), - Self::RandomChance(chance) => random.next_float() < *chance, - Self::MatchingBlocks { - blocks, - properties, - location: at_location, - } => { - let state = if *at_location { location } else { input }; - blocks.contains(&state) - && properties.iter().all(|(k, x)| { - registry - .state(state) - .and_then(|s| s.properties.get(k)) - .is_some_and(|v| v == x) - }) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn random_settings_salt_and_position() { - // ResourceLocation.hashCode: 31 * namespace + path. - let default = RandomSettings::decode(Some(&"per_piece".into())).unwrap(); - assert_eq!( - default.salt, - java_hash("lithostitched") - .wrapping_mul(31) - .wrapping_add(java_hash("default")) - ); - assert_eq!(java_hash("default"), 1544803905); - let (seed, piece, pivot, block) = (12345, [16, 64, -32], [8, 70, 8], [17, 65, -30]); - let mut ours = default.create(seed, piece, pivot, block); - let mut theirs = LegacyRandom::new(seed + i64::from(default.salt)) - .fork_positional() - .at(BlockPos::new(16, 64, -32)); - assert_eq!(ours.next_long(), theirs.next_long()); - let named: Value = serde_json::json!({"mode": "per_structure", "name": "v:roof"}); - let named = RandomSettings::decode(Some(&named)).unwrap(); - assert_eq!(named.mode, Mode::Structure); - assert_eq!( - named.salt, - java_hash("v") - .wrapping_mul(31) - .wrapping_add(java_hash("roof")) - ); - } - - #[test] - fn apply_random_draws_by_weight() { - let apply = ApplyRandom { - random: RandomSettings { - mode: Mode::Block, - salt: 0, - }, - lists: vec![ - (ProcessorList(vec![Processor::Nop]), 3), - (ProcessorList::default(), 1), - ], - total: 4, - }; - let mut ours = LegacyRandom::new(9); - let mut theirs = LegacyRandom::new(9); - for _ in 0..32 { - let draw = theirs.next_int_bound(4).unwrap(); - let picked = apply.pick(&mut ours).unwrap(); - assert_eq!(picked.0.len(), usize::from(draw < 3)); - } - } - - #[test] - fn set_block_draws_from_the_unsalted_seed() { - let (a, b) = (BlockStateId(1), BlockStateId(2)); - let set = SetBlock::decode( - &serde_json::json!({"state_provider": {"type": "minecraft:weighted_state_provider", - "entries": [{"weight": 1, "data": 1}, {"weight": 2, "data": 2}]}, - "random_mode": "per_piece"}), - |v| Ok(BlockStateId(v.as_u64().unwrap() as u32)), - ) - .unwrap(); - assert!(set.preserves()); - for x in 0..16 { - let mut java = LegacyRandom::new(77) - .fork_positional() - .at(BlockPos::new(x, 5, 3)); - let want = if java.next_int_bound(3).unwrap() < 1 { - a - } else { - b - }; - assert_eq!(set.state(77, [x, 5, 3], [0; 3], [9, 9, 9]), want); - } - } -} diff --git a/crates/horizon-world/src/worldgen/structure/ruined_portal.rs b/crates/horizon-world/src/worldgen/structure/ruined_portal.rs index 76010aa86..4f87cdd10 100644 --- a/crates/horizon-world/src/worldgen/structure/ruined_portal.rs +++ b/crates/horizon-world/src/worldgen/structure/ruined_portal.rs @@ -25,6 +25,17 @@ pub enum Vertical { InNether, } impl Vertical { + /// `RuinedPortalPiece.VerticalPlacement.getSerializedName`. + fn saved_name(self) -> &'static str { + match self { + Self::OnLandSurface => "on_land_surface", + Self::PartlyBuried => "partly_buried", + Self::OnOceanFloor => "on_ocean_floor", + Self::InMountain => "in_mountain", + Self::Underground => "underground", + Self::InNether => "in_nether", + } + } fn heightmap(self) -> HeightmapType { if self == Self::OnOceanFloor { HeightmapType::OceanFloorWg @@ -70,6 +81,9 @@ impl Setup { #[derive(Clone, Copy, Debug)] pub struct PortalProps { placement: Vertical, + mossiness: f32, + air_pocket: bool, + blackstone: bool, cold: bool, overgrown: bool, vines: bool, @@ -182,6 +196,9 @@ pub fn start( }; let props = PortalProps { placement: setup.placement, + mossiness: setup.mossiness, + air_pocket, + blackstone: setup.blackstone, cold, overgrown: setup.overgrown, vines: setup.vines, @@ -379,3 +396,28 @@ pub fn post_process(piece: &TemplatePiece, c: &mut PieceContext<'_>) -> Result<( } Ok(()) } + +impl PortalProps { + /// `RuinedPortalPiece`'s `VerticalPlacement` and `Properties` tags. + pub fn save(&self) -> [(&'static str, crate::nbt::Tag); 2] { + use crate::nbt::Tag; + let flag = |b: bool| Tag::Byte(i8::from(b)); + [ + ( + "VerticalPlacement", + Tag::String(self.placement.saved_name().into()), + ), + ( + "Properties", + Tag::compound([ + ("cold", flag(self.cold)), + ("mossiness", Tag::Float(self.mossiness.to_bits())), + ("air_pocket", flag(self.air_pocket)), + ("overgrown", flag(self.overgrown)), + ("vines", flag(self.vines)), + ("replace_with_blackstone", flag(self.blackstone)), + ]), + ), + ] + } +} diff --git a/crates/horizon-world/src/worldgen/structure/simple.rs b/crates/horizon-world/src/worldgen/structure/simple.rs index 72aab4710..d05aab20f 100644 --- a/crates/horizon-world/src/worldgen/structure/simple.rs +++ b/crates/horizon-world/src/worldgen/structure/simple.rs @@ -18,11 +18,22 @@ fn empty_processors() -> Arc { #[derive(Debug)] pub struct BuriedTreasurePiece { pub position: [i32; 3], + /// Where the chest went: the box vanilla saves once placed. + pub found: Mutex>, } impl LegacyPiece for BuriedTreasurePiece { fn bounding_box(&self) -> BoundingBox { BoundingBox::new(self.position, self.position) } + fn save(&self) -> Option { + let at = self.found.lock().unwrap().unwrap_or(self.position); + Some(super::legacy::piece_tag( + "minecraft:btp", + BoundingBox::new(at, at), + None, + 0, + )) + } fn type_name(&self) -> &'static str { "minecraft:btp" } @@ -58,6 +69,7 @@ impl LegacyPiece for BuriedTreasurePiece { } } } + *self.found.lock().unwrap() = Some(pos); create_chest(c, pos, "minecraft:chests/buried_treasure", None)?; return Ok(()); } @@ -193,10 +205,87 @@ pub fn shipwreck_too_big(template: &StructureTemplate) -> bool { template.size[0] > 32 || template.size[1] > 32 } +impl TemplatePiece { + /// `TemplateStructurePiece.addAdditionalSaveData` and its subclasses', + /// with the height a shipwreck settled on once placed. + pub fn saved(&self) -> crate::nbt::Compound { + use crate::nbt::Tag; + let flag = |b: bool| Tag::Byte(i8::from(b)); + let adjusted = match self.handler { + TemplateHandler::Shipwreck { .. } + | TemplateHandler::OceanRuin { .. } + | TemplateHandler::Igloo => *self.state.lock().unwrap(), + _ => None, + }; + let dy = adjusted.map_or(0, |y| y - self.position[1]); + let template_dy = if matches!(self.handler, TemplateHandler::Igloo) { + 0 + } else { + dy + }; + let mut tag = super::legacy::piece_tag( + self.type_name, + self.bounding_box.moved([0, dy, 0]), + Some(Direction::North), + 0, + ); + // Mansion and End City pieces keep the name inside their folder. + let name = self + .template_name + .strip_prefix("minecraft:woodland_mansion/") + .or_else(|| self.template_name.strip_prefix("minecraft:end_city/")) + .unwrap_or(&self.template_name); + let rotation = || Tag::String(self.rotation.saved_name().into()); + let mut entries = vec![ + ("TPX", Tag::Int(self.position[0])), + ("TPY", Tag::Int(self.position[1] + template_dy)), + ("TPZ", Tag::Int(self.position[2])), + ("Template", Tag::String(name.into())), + ]; + match self.handler { + TemplateHandler::Igloo => entries.push(("Rot", rotation())), + TemplateHandler::Shipwreck { beached } => entries.extend([ + ("isBeached", flag(beached)), + ("Rot", rotation()), + ("height_adjusted", flag(adjusted.is_some())), + ]), + TemplateHandler::OceanRuin { + warm, + large, + integrity, + } => entries.extend([ + ("Rot", rotation()), + ("Integrity", Tag::Float(integrity.to_bits())), + ( + "BiomeType", + Tag::String(if warm { "WARM" } else { "COLD" }.into()), + ), + ("IsLarge", flag(large)), + ]), + TemplateHandler::RuinedPortal(props) => { + entries.push(("Rotation", rotation())); + entries.push(("Mirror", Tag::String(self.mirror.saved_name().into()))); + entries.extend(props.save()); + } + TemplateHandler::Mansion => entries.extend([ + ("Rot", rotation()), + ("Mi", Tag::String(self.mirror.saved_name().into())), + ]), + TemplateHandler::Plain => entries.push(("Rot", rotation())), + } + for (key, value) in entries { + tag.insert(key.into(), value); + } + tag + } +} impl LegacyPiece for TemplatePiece { fn bounding_box(&self) -> BoundingBox { self.bounding_box } + fn save(&self) -> Option { + Some(self.saved()) + } fn after_place(&self, c: &mut PieceContext<'_>, pieces: &[BoundingBox]) -> Result<()> { match self.handler { TemplateHandler::Mansion => super::mansion::after_place(c, pieces), @@ -232,6 +321,8 @@ impl LegacyPiece for TemplatePiece { self.position[1] + height - 90 - 1, self.position[2], ]; + // The box stays moved; templatePosition goes back after placing. + *self.state.lock().unwrap() = Some(position[1]); if let Some(markers) = self.place_at(c, position, &[])? { for (marker, pos) in markers { if marker == "chest" { @@ -362,6 +453,21 @@ impl Scattered { pub fn frame(&self) -> PieceFrame { *self.frame.lock().unwrap() } + /// `ScatteredFeaturePiece`'s tag: the box and the height it settled on. + pub fn saved(&self, id: &str) -> crate::nbt::Compound { + use crate::nbt::Tag; + let f = self.frame(); + let mut tag = super::legacy::piece_tag(id, f.bounding_box, f.orientation, 0); + for (key, value) in [ + ("Width", self.width), + ("Height", self.height), + ("Depth", self.depth), + ("HPos", *self.height_position.lock().unwrap()), + ] { + tag.insert(key.into(), Tag::Int(value)); + } + tag + } /// updateAverageGroundHeight over the part of the box inside the chunk. pub fn average_ground(&self, c: &mut PieceContext<'_>, offset: i32) -> Result { if *self.height_position.lock().unwrap() >= 0 { @@ -419,12 +525,15 @@ impl Scattered { #[derive(Debug)] pub struct SwampHutPiece { pub base: Scattered, + /// spawnedWitch and spawnedCat: both spawn in the same chunk. + spawned: Mutex, } impl SwampHutPiece { pub fn new(random: &mut impl RandomSource, west: i32, north: i32) -> Self { let direction = random_horizontal(random); Self { base: Scattered::new(west, 64, north, 7, 7, 9, direction), + spawned: Mutex::new(false), } } } @@ -432,6 +541,13 @@ impl LegacyPiece for SwampHutPiece { fn bounding_box(&self) -> BoundingBox { self.base.frame().bounding_box } + fn save(&self) -> Option { + let mut tag = self.base.saved("minecraft:tesh"); + let spawned = crate::nbt::Tag::Byte(i8::from(*self.spawned.lock().unwrap())); + tag.insert("Witch".into(), spawned.clone()); + tag.insert("Cat".into(), spawned); + Some(tag) + } fn type_name(&self) -> &'static str { "minecraft:tesh" } @@ -500,6 +616,7 @@ impl LegacyPiece for SwampHutPiece { c.world .entity(super::legacy::spawn_nbt(id, pos, true, true))?; } + *self.spawned.lock().unwrap() = true; } Ok(()) } diff --git a/crates/horizon-world/src/worldgen/structure/start.rs b/crates/horizon-world/src/worldgen/structure/start.rs index ea4549131..810332c61 100644 --- a/crates/horizon-world/src/worldgen/structure/start.rs +++ b/crates/horizon-world/src/worldgen/structure/start.rs @@ -694,6 +694,13 @@ impl Piece { Self::Legacy(p) => p.type_name(), } } + /// `StructurePiece.createTag`; None for a type Horizon cannot save yet. + pub fn save(&self) -> Option { + match self { + Self::Pool(p) => Some(p.save()), + Self::Legacy(p) => p.save(), + } + } } fn legacy(pieces: Vec) -> Vec { pieces @@ -735,6 +742,26 @@ pub struct StructureStart { /// Piece union, inflated by 12 for terrain-adapting structures. pub bounding_box: BoundingBox, } +impl StructureStart { + /// `StructureStart.createTag` in its own chunk; None while one of its + /// pieces cannot be saved. `references` counts `addReference` calls, + /// which only locating structures makes: 0 in a freshly made world. + pub fn save(&self) -> Option { + use crate::nbt::Tag; + let children: Option> = self + .pieces + .iter() + .map(|p| p.save().map(Tag::Compound)) + .collect(); + Some(Tag::compound([ + ("id", Tag::String(self.name.as_str().into())), + ("ChunkX", Tag::Int(self.chunk.x)), + ("ChunkZ", Tag::Int(self.chunk.z)), + ("references", Tag::Int(0)), + ("Children", Tag::List(children?)), + ])) + } +} /// A block of a noise column (ChunkGenerator.getBaseColumn), by what /// structures ask of it. @@ -935,6 +962,7 @@ fn try_generate( let stub = top_of_chunk_center(context, chunk, HeightmapType::OceanFloorWg)?; let piece = super::simple::BuriedTreasurePiece { position: [chunk.x * 16 + 9, 90, chunk.z * 16 + 9], + found: std::sync::Mutex::new(None), }; (stub, legacy(vec![piece])) } diff --git a/crates/horizon-world/src/worldgen/structure/stronghold.rs b/crates/horizon-world/src/worldgen/structure/stronghold.rs index 94042ba6f..29f7cd149 100644 --- a/crates/horizon-world/src/worldgen/structure/stronghold.rs +++ b/crates/horizon-world/src/worldgen/structure/stronghold.rs @@ -1075,6 +1075,48 @@ impl LegacyPiece for StrongholdPiece { fn bounding_box(&self) -> BoundingBox { self.frame.bounding_box } + fn save(&self) -> Option { + use crate::nbt::Tag; + let flag = |b: bool| Tag::Byte(i8::from(b)); + let mut tag = super::legacy::piece_tag( + self.type_name(), + self.frame.bounding_box, + self.frame.orientation, + self.depth, + ); + // SmallDoorType.LEGACY_CODEC: the enum name. + let door = match self.door { + Door::Opening => "OPENING", + Door::Wood => "WOOD_DOOR", + Door::Grates => "GRATES", + Door::Iron => "IRON_DOOR", + }; + let placed = *self.placed.lock().unwrap(); + let [a, b, c, d] = self.flags; + let mut entries = vec![("EntryDoor", Tag::String(door.into()))]; + match self.kind { + Kind::ChestCorridor => entries.push(("Chest", flag(placed))), + Kind::Filler => entries.push(("Steps", Tag::Int(self.value))), + Kind::FiveCrossing => entries.extend([ + ("leftLow", flag(a)), + ("leftHigh", flag(b)), + ("rightLow", flag(c)), + ("rightHigh", flag(d)), + ]), + Kind::Library => entries.push(("Tall", flag(a))), + Kind::PortalRoom => entries.push(("Mob", flag(placed))), + Kind::RoomCrossing => entries.push(("Type", Tag::Int(self.value))), + // StartPiece is a StairsDown that is the source. + Kind::StairsDown => entries.push(("Source", flag(false))), + Kind::Start => entries.push(("Source", flag(true))), + Kind::Straight => entries.extend([("Left", flag(a)), ("Right", flag(b))]), + _ => {} + } + for (key, value) in entries { + tag.insert(key.into(), value); + } + Some(tag) + } fn type_name(&self) -> &'static str { match self.kind { Kind::Straight => "minecraft:shs", diff --git a/crates/horizon-world/src/worldgen/structure/temple.rs b/crates/horizon-world/src/worldgen/structure/temple.rs index 195f37791..c328f0f55 100644 --- a/crates/horizon-world/src/worldgen/structure/temple.rs +++ b/crates/horizon-world/src/worldgen/structure/temple.rs @@ -82,6 +82,16 @@ impl LegacyPiece for DesertPyramidPiece { fn bounding_box(&self) -> BoundingBox { self.base.frame().bounding_box } + fn save(&self) -> Option { + let mut tag = self.base.saved("minecraft:tedp"); + for (i, placed) in self.placed_chest.lock().unwrap().iter().enumerate() { + tag.insert( + format!("hasPlacedChest{i}").as_str().into(), + crate::nbt::Tag::Byte(i8::from(*placed)), + ); + } + Some(tag) + } fn type_name(&self) -> &'static str { "minecraft:tedp" } @@ -448,6 +458,22 @@ impl LegacyPiece for JungleTemplePiece { fn bounding_box(&self) -> BoundingBox { self.base.frame().bounding_box } + fn save(&self) -> Option { + let mut tag = self.base.saved("minecraft:tejp"); + let placed = *self.placed.lock().unwrap(); + for (key, value) in [ + "placedMainChest", + "placedHiddenChest", + "placedTrap1", + "placedTrap2", + ] + .into_iter() + .zip(placed) + { + tag.insert(key.into(), crate::nbt::Tag::Byte(i8::from(value))); + } + Some(tag) + } fn type_name(&self) -> &'static str { "minecraft:tejp" } diff --git a/docs/agent-control.md b/docs/agent-control.md index 903a48b2f..8573bb711 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -3,7 +3,7 @@ A player's own AI agent (Claude Code, Codex, Cursor, any MCP client) or a script can do everything a player does with the mouse and keyboard, in the launcher and in the game. This page covers developers: the surfaces, the full inventory, and the rules every new command follows. -Players read [Control Horizon with your AI agent](../website/docs/agents.md) +Players read [Control Horizon with your AI agent](https://playhorizon.dev/docs/agents) (playhorizon.dev/docs/agents). The command lists are generated, so they can't drift: diff --git a/docs/api/README.md b/docs/api/README.md index ef3649105..ce28dc262 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -53,4 +53,4 @@ Every page has the same parts: **Overview** (what you can build), **Guide** (whe The website shows these pages at [playhorizon.dev/docs/api](https://playhorizon.dev/docs/api), built from this folder, and a summary for players and authors at [playhorizon.dev/docs/apis](https://playhorizon.dev/docs/apis) -(`website/docs/apis.md`). +(in the website's repository, ludovic111/horizon-site). diff --git a/docs/api/mod-index.md b/docs/api/mod-index.md index 6b807d950..ea605fbc7 100644 --- a/docs/api/mod-index.md +++ b/docs/api/mod-index.md @@ -1,7 +1,7 @@ # The registry index and `.hpack` Horizon's registry lists what is made for Horizon and reviewed: mods (`.hmod`), modpacks (`.hpack`), resource packs -and, once their format exists, Horizon shader packs. Submissions are pull requests in +and Horizon shader packs (`.hshader`, [shaders.md](../shaders.md)). Submissions are pull requests in [ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods); merging one publishes it. The launcher and the website read one file, `index.json`, which that repository's CI builds. The Rust types and every check below are `horizon_mods::index` and `horizon_mods::modpack` (feature `index`); the sample is @@ -18,10 +18,10 @@ Level: preview ([stability.md](stability.md)). Schema 1. "projects": [{ "id": "coords_hud", "kind": "mod", "name": "Coordinates HUD", "summary": "One line.", "description": "Markdown.", "authors": ["Horizon"], "owners": ["ludovic111"], - "license": "MIT", "source": "https://github.com/...", "icon": "https://...png", "key": "RWT...", + "license": "MIT", "source": "https://github.com/...", "icon": "https://...png", "key": "RWT...", "downloads": 1234, "versions": [{ "version": "0.1.0", "published": "2026-10-24", "changelog": "", - "api": "0.1", "side": "client", "capabilities": ["game-read", "hud", "chat"], "game": "26.3", + "api": "0.1", "side": "client", "capabilities": ["game-read", "hud", "chat"], "api_level": 0, "game": "26.3", "file": { "name": "coords_hud-0.1.0.hmod", "url": "https://github.com/ludovic111/horizon-mods/releases/download/...", "size": 438, "sha256": "e113...", "signature": "untrusted comment: ...\n...\ntrusted comment: ...\n...\n" } }] @@ -41,8 +41,10 @@ Level: preview ([stability.md](stability.md)). Schema 1. | `icon` | `https://`, optional. | | `key` | The author's [minisign](https://jedisct1.github.io/minisign/) public key. When set, every version must be signed with it; an update signed with another key is refused. Absent: the project is unsigned and the launcher says so. | | `versions` | Newest first, at least one. | +| `downloads` | How many times its files were downloaded, every version together: GitHub's count of each release asset, summed by the publish job, which runs every hour. Absent until a file is downloaded. Read-only: never in an entry. | | `api` | A mod's API line (its manifest's `api`); a modpack's manifest format (`1`). | | `side`, `capabilities` | A mod's, read from its manifest by CI, never typed by hand. | +| `api_level` | The mod API level a mod's code needs ([stability.md](stability.md#mod-api-levels)): its newest imported function's, read from the file by CI. Absent for content-only mods and in indexes built before levels. Installers take the newest version whose level the running game serves. | | `game` | The Minecraft version it is for. | | `requires` | A modpack's mods: `{ id, version, sha256 }`, each a version in this index with that hash. | | `file` | A plain name with the kind's extension (`.hmod`, `.hpack`, `.zip`), an `https://` URL, the size (at most 64 MiB), the lowercase hex SHA-256, and the `.minisig` text when the project has a key. | @@ -57,9 +59,11 @@ changing what one means, makes schema 2. 1. Its size and SHA-256 are the ones the entry lists, and it isn't revoked. 2. With a project key: its signature verifies with that key (minisign's prehashed and legacy signatures both). -3. What the file says matches the entry: a mod's `horizon-mod.toml` (id, version, API, side, capabilities) and its +3. What the file says matches the entry: a mod's `horizon-mod.toml` (id, version, API, side, capabilities), its API + level when the entry lists one, and its content's fence ([mods.md](../mods.md#content-mods-api-02)); a modpack's manifest (id, version, format, game, - mods); a resource pack's `pack.mcmeta`. + mods); a resource pack's `pack.mcmeta`; a shader pack's `horizon_shader_api::check` and manifest (id, version, + shader API). 4. A kind this build can't install is refused with "this Horizon can't install a … yet". The game still checks a mod's component imports against its capabilities when it starts it, as for any mod. @@ -99,7 +103,7 @@ own check (`index::inspect`): a mod's API, side and capabilities, a modpack's mo ```toml id = "coords_hud" # the file's name, and the manifest's id -kind = "mod" # mod | modpack | resourcepack; shader once its format exists +kind = "mod" # mod | modpack | resourcepack | shader name = "Coordinates HUD" summary = "One line, at most 160 characters." description = """Markdown.""" # optional @@ -156,10 +160,15 @@ How the repository runs (its workflows are in `.github/workflows/`): - **Taking a file down** is a pull request adding it to `revoked.toml`. Removing an entry outright (a test, a project nobody installed) fails `registry review` on purpose and needs an administrator's merge. -Kinds open to submissions: `mod`, `modpack`, `resourcepack`. `shader` is in the schema and stays closed until -Horizon's own shader pack format exists (Iris and OptiFine packs are for the Modrinth browser); its check will compile -the pack in a headless Horizon. A new kind gets its own check before it opens. +Kinds open to submissions: `mod`, `modpack`, `resourcepack`, `shader`. A shader pack (`.hshader`) passes +`horizon_shader_api::check`, the check the game runs before it loads one (`horizon-shader check`, [shaders.md](../shaders.md)): +every stage validated by naga and translated for Vulkan, Metal and DirectX 12; its manifest's id, version and shader +API line are its facts. Iris and OptiFine packs are for the Modrinth browser. A new kind gets its own check before it +opens. ## Changelog +- 2026-10-26: `projects[].downloads`, GitHub's download counts of a project's files, refreshed every hour. +- 2026-10-26: the launcher installs `shader` files into `shaderpacks/`; the sample index lists one (`glow`). +- 2026-10-25: `shader` opens, checked by `horizon_shader_api::check`; its files are `.hshader`. - Schema 1 (2026-10-24): kinds `mod`, `modpack`, `resourcepack`, `shader`; `.hpack` format 1. diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 318c256d4..b80df2597 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -77,7 +77,7 @@ The mod's log, in the game's log under the mod's id. Always granted. Access: capability `game-read`. -Capability `game-read`: the local player and the world clock. +Capability `game-read`: the local player, the world clock and the block the player looks at. ### record `player` @@ -99,10 +99,25 @@ Capability `game-read`: the local player and the world clock. | `game-time` | `u64` | Ticks since the world was created. | | `day-time` | `u64` | 24000 ticks per day; 0 is 6:00 on the first day. | +### record `hit` + +A block along the player's view. + +| Field | Type | | +| --- | --- | --- | +| `block-x` | `s32` | The block hit. | +| `block-y` | `s32` | | +| `block-z` | `s32` | | +| `x` | `f64` | The point on its face where the view meets it. | +| `y` | `f64` | | +| `z` | `f64` | | +| `block` | `string` | `minecraft:stone`, ... | + | Function | Signature | What it does | | --- | --- | --- | | `get-player` | `func() -> option` | None on the title screen. | | `get-clock` | `func() -> option` | None on the title screen. | +| `raycast` | `func(distance: f32) -> option` | The first block along the player's view (fluids pass), up to `distance` blocks (at most 1024; past the loaded chunks, across the distant terrain when LOD Distance is on), as of this tick. None when nothing is hit, and on the title screen. | ## `chat` @@ -162,6 +177,7 @@ Capability `command`: vanilla commands run as the player, with the player's perm | Function | Signature | What it does | | --- | --- | --- | | `run` | `func(line: string)` | `line` without its slash; its feedback goes to the chat as if typed. | +| `run-quiet` | `func(lines: list)` | `lines` (each without its slash, at most 256 a call) as `run` does, but with nothing in the chat: no feedback and no failure. In single player and on Horizon's servers, each runs exactly as the player typing it would (the same rights), in order, 64 a tick, and the server logs one line a batch; one that carries a chat message (`say`, `msg`) is refused. On any other server they go as chat commands, and there their feedback shows: a mod running many should say so. | ## `ui` diff --git a/docs/api/shaders.md b/docs/api/shaders.md index 675c5e3fc..faf662b0e 100644 --- a/docs/api/shaders.md +++ b/docs/api/shaders.md @@ -91,11 +91,13 @@ pack exist ([stability.md](stability.md)). ## Not there yet -The stages and everything that runs a pack (the format above is read and checked). The order: the renderer hooks shared with the format loader, then the material and post -stages with options and hot reload, then the overlay, lighting, sky and shadow stages. +Option values other than the defaults (the three stages run, with hot reload), then the overlay, lighting and shadow +stages. ## Changelog +- 2026-10-26: the material stage in the game: terrain's surfaces through the pack's `material`. +- 2026-10-26: the sky stage in the game: the pack's `sky` gets vanilla's colour along each ray. - 2026-10-26: the post stage in the game (`HORIZON_SHADER_PACK`, hot reload with the last good pack kept). - 2026-10-25: `horizon-shader` (`new`, `check`, `pack`), three templates, the example pack and the guide. - 2026-10-25: the check (naga validation, signatures, options, budgets, SPIR-V/Metal/HLSL translation). diff --git a/docs/api/stability.md b/docs/api/stability.md index 85e37ba08..9de3b74d3 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -34,6 +34,22 @@ Not breaking: a new function, interface, capability, command, endpoint, tool or argument or JSON field (readers must ignore fields they don't know); a new WIT function or type (mods built earlier don't import it); faster, or fixing behaviour to what the reference already says. +### Mod API levels + +A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every +function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`. A mod needs the level of +the newest function it imports (a component imports only the functions it calls). The level is read from the file, +never declared: + +- the game refuses a mod that needs a function it doesn't have, with "made for a newer Horizon (it uses + render.set-minimap): update Horizon to run it", instead of a linking error; +- the registry's CI writes each mod version's level into the index (`api_level`), and installers take the newest + version the running game can run. + +A pull request that adds a function lists it in `api_level.rs` at the next level and raises `LEVEL`; the +`api_reference` test fails until it does. + ## Stability levels Every part of an API has one level, shown in its reference and its surface page. diff --git a/docs/architecture.md b/docs/architecture.md index f95073e57..0350bd1ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ file selects ~103. | 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 | | 36 | Audio and music (38) | `L/audio.rs`, `L/sound_engine.rs`, `L/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle | -| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e --server-thread` (portals, levels, distance, lava) | +| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) | | 38 | Command parsing (34) | `L/command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`) | command_parse (corpus `tools/command_parse_corpus.py`) | | 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `tools/perf_bench.py`, `L/telemetry.rs`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) | | 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet | diff --git a/docs/design-system.md b/docs/design-system.md index e1417f749..0b171d4c2 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -8,8 +8,8 @@ Horizon's own settings are pages built from vanilla's widgets, the way mods add The tokens live in one place, `crates/horizon-theme`. The launcher (`launcher/src/theme.rs`) and the game (`crates/horizon-client/src/ui_theme.rs`) read them as Rust constants, and the website reads them as CSS custom properties generated from the same crate -(`cargo run -p xtask -- tokens` writes `website/src/tokens.css`; a test fails when the file is -out of date). Change a value in the crate and all three follow. +(`cargo run -p xtask -- tokens` writes `crates/horizon-theme/tokens.css`, which the website's build +reads from main; a test fails when the file is out of date). Change a value in the crate and all three follow. ## Where it comes from @@ -140,7 +140,7 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice landscape banner with the hero, the version picker and Play; Mods holds the mod, resource pack and modpack tabs; Versions and Patch notes are lists of panels; Settings is grouped panels of rows. Dark by default, Light or System in Settings (Appearance). -- **Website** (`website/`): the Blocks look below. The same page structure as before (what it +- **Website** (ludovic111/horizon-site): the Blocks look below. The same page structure as before (what it is, screenshots, parity, launcher, patch notes, community), headings in Pixelify Sans, lists as stacks of panels, the stone texture behind the page and Horizon's pixel landscape (a dawn or a sunset) behind the hero. Light and dark follow the system, with a toggle. A giant wordmark in @@ -168,11 +168,16 @@ of the main action. The game's menus keep `LIGHT` and `DARK`. ships no third-party pictures. The Horizon instance shows a grass block drawn from the player's own prepared Minecraft textures. Sources are named in text ("from Modrinth"): Modrinth's logo is all rights reserved, so it is not shown. +- **Partner banner**: the one exception to shipping no third-party pictures. Sculk, a server host, + sponsors Horizon: its banner (`launcher/assets/sculk-banner.webp`, `launcher/src/partner.rs`) sits + under a muted mono "Partner" label at the end of the Servers page and of Home, below Play, and opens + Sculk's page in the browser. The website shows the same banner the same way on Home and the hosting + docs, as a `rel="sponsored"` link. - **Pixel art drawn in code.** The stone behind the pages is close shades of the page colour (`TILE_SHADES`), never Minecraft's stone; the landscape is a banded sky, a sun cut in slices like the mark, hills and square trees (a sunset in dark, a dawn in light). The launcher bakes them in `launcher/src/art.rs` at the window's scale so each texel covers whole device pixels; the website - draws them in `website/scripts/art.mjs`. + draws them in its `scripts/art.mjs`. - **Icons**: Pixelarticons (MIT), 24 unit pixel SVGs in `crates/horizon-theme/icons/pixel/`, tinted with the text colour. - **Contrast**: every text colour meets WCAG AA (4.5:1) on the panels, the page and the lightest diff --git a/docs/extras.md b/docs/extras.md index aa87c1adf..ac0196c17 100644 --- a/docs/extras.md +++ b/docs/extras.md @@ -1,6 +1,6 @@ # Horizon extras -Optional conveniences built into the client: fullbright, dynamic lights, a minimap, auto-reconnect, +Optional conveniences built into the client: fullbright, dynamic lights, auto-reconnect, Mouse Tweaks-style inventory controls and inventory sorting. **Every one is off by default**: a fresh install looks and behaves exactly like vanilla 26.3, and turning an extra off again restores vanilla's behaviour completely. @@ -12,8 +12,8 @@ again restores vanilla's behaviour completely. | Settings, parsing and saving | `crates/horizon-client/src/extras.rs` | | FPS and click counters (for mods) | `extras.rs` (pure logic, unit tested) | | Dynamic lights: item glow, nearest sources | `crates/horizon-client/src/dynamic_light.rs` (unit tested), `game/dynamic_lights.rs`, `terrain.wgsl` and `entity.wgsl` (`dynamic_block_light`) | -| HUD drawing (minimap frame, corner stacking) | `crates/horizon-client/src/extras_hud.rs` | -| Minimap colours, tiles and image | `crates/horizon-client/src/minimap.rs` | +| HUD drawing (a mod's minimap, corner stacking) | `crates/horizon-client/src/extras_hud.rs` | +| Minimap colours, tiles and image (for `render.set-minimap`) | `crates/horizon-client/src/minimap.rs` | | Reconnect policy and backoff | `crates/horizon-client/src/reconnect.rs` | | Game glue (input hooks, per-frame update) | `crates/horizon-client/src/game/extras.rs` | | Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/tweaks.rs` | @@ -62,7 +62,7 @@ in Controls, and adding one would put a non-vanilla mapping into `options.txt`. ### Moved to the mod browser -The **FPS counter**, **Keystrokes** and **Zoom** are official mods now (`mods/official/`, [mods.md](mods.md#horizons-official-mods)), +The **FPS counter**, **Keystrokes**, **Zoom** and the **Minimap** are official mods now (`mods/official/`, [mods.md](mods.md#horizons-official-mods)), with the same look and options. On the first start after the update, a player who had one on gets the mod installed from Horizon's registry and turned on, with their corner and options carried over (Zoom's key too, into `horizon-mods.txt`, when it wasn't Z), and reads one line in the chat ("Zoom moved to the mod browser: installed for @@ -71,19 +71,14 @@ SHA-256, signature, manifest); if it fails (offline), the next start tries again keys and lists what was done in `movedToMods`. Code: `crates/horizon-client/src/moved_extras.rs`, `game/moved_extras.rs`. -### Minimap (`minimap`, default off) -- A corner map (top right by default), square or round, small/medium/large, - zoom 0.5 to 4 blocks per pixel, rotating with the player or north up, with the - player arrow (vanilla's map decoration sprite). -- Colours are vanilla's `MapColor` per block, as map items use, with the map - item's height shading and water-depth dithering. -- Underground, in the Nether and under any roof, it maps the floor around the - player's height instead of the top block, so caves read sensibly. -- Cost: one cached 16x16 tile per loaded chunk (packed map colour and height). - Tiles are filled nearest first and refreshed when blocks change, within a time - budget of 0.3 ms a frame; composing the 128x128 image is a few thousand - lookups, done only when the view moved or a tile changed, and uploaded into a - dedicated atlas slot. With the minimap off nothing runs. +The game still draws the minimap, for the mod (`render.set-minimap`, [mods.md](mods.md)): +- a corner map (top right by default), square or round, small/medium/large, zoom 0.5 to 4 blocks per pixel, rotating + with the player or north up, with the player arrow (vanilla's map decoration sprite); +- vanilla's `MapColor` per block, as map items use, with the map item's height shading and water-depth dithering; +- underground, in the Nether and under any roof, the floor around the player's height instead of the top block; +- one cached 16x16 tile per loaded chunk (packed map colour and height), filled nearest first and refreshed when + blocks change within 0.3 ms a frame; the 128x128 image is recomposed only when the view moved or a tile changed, and + uploaded into a dedicated atlas slot. With no mod showing a map, nothing runs. ### Auto-reconnect (`autoReconnect`, default off) - On the "Connection lost" screen after a multiplayer connection drops, a @@ -140,11 +135,11 @@ and the container screen tests are unchanged). the world, like vanilla's HUD. Extras in the top left corner give way to the debug screen (F3), which shows the FPS anyway. - Corners stack: extras placed in the same corner stack downward (top corners) - or upward (bottom corners) in the order minimap, then mods' panels. The top + or upward (bottom corners) in the order a mod's minimap, then mods' panels. The top right corner starts below the status effect icons. ## Perf budget Off: a flag check per frame per extra (no allocations, no world access). -On: the minimap stays under 0.3 ms of CPU +A mod's minimap stays under 0.3 ms of CPU per frame (tile budget) plus a 64 KiB texture upload when its image changes. diff --git a/docs/graphics.md b/docs/graphics.md index d4261df81..dcba29773 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -21,14 +21,16 @@ Every row has a one-line description and its cost in frame time (none, low, medi | Quality | smooth lighting, mipmap levels, biome blend, clouds, cloud distance, weather radius, particles, entity shadows, cutout leaves | `options.txt` | | | sky, fog, weather, animated textures | `horizon-graphics.txt` | | Display | FOV, max framerate, VSync, fullscreen, GUI scale | `options.txt` | -| | resolution scale, anti-aliasing (FXAA), present mode, frame pacing | `horizon-graphics.txt` | +| | resolution scale, anti-aliasing (FXAA), HDR, paper white, peak brightness, present mode, frame pacing | `horizon-graphics.txt` | | Performance | battery mode, worker threads, upload budget per frame | `horizon-graphics.txt` | | Overlay | performance overlay: FPS, frame/CPU/GPU times, frame-time graph | `horizon-graphics.txt` | -`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, no upload budgets, sky, fog and weather on, 100% resolution, no FXAA, the present mode following VSync. VSync on always waits for the display (Fifo), in a window and in fullscreen, as in vanilla; the present mode setting chooses how frames are shown only while VSync is off. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, which changes timing, not pixels. +`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, no upload budgets, sky, fog and weather on, 100% resolution, no FXAA, HDR off, the present mode following VSync. VSync on always waits for the display (Fifo), in a window and in fullscreen, as in vanilla; the present mode setting chooses how frames are shown only while VSync is off. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, which changes timing, not pixels. **Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are. +**HDR** (Display, off by default) presents to an scRGB swapchain (`Rgba16Float`, extended linear sRGB) on displays and compositors that offer it. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain. + Which settings each screen shows, their descriptions and costs are in `crates/horizon-client/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them. ## Distant terrain diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index 7c9562441..1e9fb7722 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -132,11 +132,11 @@ MCP: `account_offline`. ## `horizon-launcher settings` -Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off. +Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it). horizon-launcher settings [KEY] [VALUE] -- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation) +- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy) - `VALUE`: The new value (with KEY) MCP: `settings`. @@ -321,7 +321,7 @@ Search for packs Horizon loads. horizon-launcher mods search [TEXT]... - `TEXT` -- `--kind` (mod, resource, modpack) +- `--kind` (mod, resource, modpack, shader) - `--everything`: Also what was made for other versions or runs only partly, each marked - `--source` (modrinth, curseforge, horizon) diff --git a/docs/media/banner.html b/docs/media/banner.html index 47037a7e2..2f727e8bd 100644 --- a/docs/media/banner.html +++ b/docs/media/banner.html @@ -1,10 +1,10 @@ + Rendered with Playwright (see docs/media/brand/README.md). --> - - -
- -
HorizonPLAYHORIZON.DEV
-

The game you know, rebuilt in Rust.

-

Minecraft: Java Edition 26.3, checked against vanilla by differential harnesses. Unofficial; not associated with Mojang or Microsoft.

-
- - diff --git a/website/public/og.png b/website/public/og.png deleted file mode 100644 index 1b9f270e7..000000000 Binary files a/website/public/og.png and /dev/null differ diff --git a/website/public/robots.txt b/website/public/robots.txt deleted file mode 100644 index 7cd2cc71e..000000000 --- a/website/public/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://playhorizon.dev/sitemap.xml diff --git a/website/railway.json b/website/railway.json deleted file mode 100644 index 3bbe9dfb3..000000000 --- a/website/railway.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "Dockerfile", - "watchPatterns": ["/website/**", "/README.md", "/launcher/links.json", "/docs/api/**"] - }, - "deploy": { - "healthcheckPath": "/", - "healthcheckTimeout": 60, - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 5 - } -} diff --git a/website/scripts/art.mjs b/website/scripts/art.mjs deleted file mode 100644 index 160d64914..000000000 --- a/website/scripts/art.mjs +++ /dev/null @@ -1,151 +0,0 @@ -// Horizon's pixel art for the website, drawn in code like the launcher's (launcher/src/art.rs): -// the stone tile behind the pages and the landscape behind the hero, in both appearances. -// Each texel is one pixel of the PNG; the CSS scales them up with `image-rendering: pixelated`. -// Written by `npm run assets` (or `node scripts/art.mjs`) into public/media/. -import { readFile } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import sharp from "sharp"; - -const site = join(dirname(fileURLToPath(import.meta.url)), ".."); -const repo = join(site, ".."); - -// The same small fixed random sequence as art.rs (xorshift), so the art is the same every time. -function rng(seed) { - let x = seed >>> 0; - return () => { - x = (x ^ (x << 13)) >>> 0; - x = (x ^ (x >>> 17)) >>> 0; - x = (x ^ (x << 5)) >>> 0; - return Math.fround((x >>> 8) / (1 << 24)); - }; -} - -const channels = (c) => [(c >> 16) & 255, (c >> 8) & 255, c & 255]; -const shade = (c, by) => channels(c).reduce((out, v) => (out << 8) | Math.min(255, Math.max(0, v + by)), 0); -const mix = (a, b, t) => { - const [x, y] = [channels(a), channels(b)]; - return x.reduce((out, v, i) => (out << 8) | Math.round(v + (y[i] - v) * t), 0); -}; - -// `n` by `n` texels of stone: the page colour in a few close shades, with darker flecks. -export function stone(page, shades, n) { - const s = shades.map((by) => shade(page, by)); - const next = rng(0x9e3779b9); - const out = Array.from({ length: n * n }, () => { - const v = next(); - return s[v < 0.5 ? 0 : v < 0.78 ? 1 : v < 0.93 ? 2 : 3]; - }); - for (let k = 0; k < Math.floor((n * n) / 50); k++) { - const x = Math.floor(next() * n); - const y = Math.floor(next() * n); - const len = 1 + Math.floor(next() * 3); - for (let i = 0; i < len; i++) { - out[y * n + ((x + i) % n)] = s[4]; - out[((y + 1) % n) * n + ((x + i + 1) % n)] = s[4]; - } - } - return out; -} - -export const SUNSET = { - sky: [0x0a0f2c, 0x26206a, 0x4b2a7e, 0x7c3a88, 0xc4527a, 0xee6e5a, 0xffb25e], - sun: [0xfff6cf, 0xffd272, 0xff9a44], - far: 0x4a2f7e, mid: 0x2b1f5e, near: 0x151a45, top: 0x3d2f80, trees: 0x0e1236, -}; -export const DAWN = { - sky: [0x8d7fd8, 0xa596e2, 0xbcaeea, 0xd2c4f0, 0xe7d2ee, 0xf6dce4, 0xffe6d6], - sun: [0xffffff, 0xfff1d0, 0xffd9a8], - far: 0xa898dc, mid: 0x8676c8, near: 0x6a5ab4, top: 0x9a8ae0, trees: 0x4c3e94, -}; - -// A blocky landscape, `w` by `h` texels: banded sky, a sun half set behind the far hills and cut -// into slices like Horizon's mark, flat clouds, three ridges of hills and a few square trees. -export function scene(p, seed, w, h) { - const next = rng((Math.imul(seed, 0x2545f491) | 1) >>> 0); - const out = new Array(w * h).fill(0); - const horizon = Math.floor((h * 62) / 100); - const band = horizon / p.sky.length; - for (let y = 0; y < h; y++) { - const f = y / band; - const i = Math.min(Math.floor(f), p.sky.length - 1); - const dither = f - i > 0.88 && i + 1 < p.sky.length; - for (let x = 0; x < w; x++) out[y * w + x] = dither && (x + y) % 2 === 1 ? p.sky[i + 1] : p.sky[i]; - } - const rect = (x, y, rw, rh, c) => { - for (let yy = Math.max(y, 0); yy < Math.min(y + rh, h); yy++) - for (let xx = Math.max(x, 0); xx < Math.min(x + rw, w); xx++) out[yy * w + xx] = c; - }; - for (let k = 0; k < 4; k++) { - const cx = Math.floor(next() * w); - const cy = Math.floor(horizon * (0.12 + next() * 0.4)); - const cw = 10 + Math.floor(next() * 14); - const under = p.sky[Math.min(Math.floor(cy / band) + 1, p.sky.length - 1)]; - const c = mix(under, 0xffffff, 0.12); - rect(cx, cy, cw, 2, c); - rect(cx + 3, cy - 2, Math.max(cw - 6, 0), 2, c); - } - const r = Math.floor(h * 0.2); - const [cx, cy] = [Math.floor(w * 0.68), horizon - Math.floor(h * 0.11)]; - for (let dy = -r; dy <= 0; dy++) { - const half = Math.round(Math.sqrt(r * r - dy * dy)); - const t = (dy + r) / r; - const c = p.sun[t < 0.4 ? 0 : t < 0.75 ? 1 : 2]; - if ((cy + dy) % 5 !== 0 || dy < -r * 0.6) rect(cx - half, cy + dy, half * 2, 1, c); - } - const ridge = (base, amp, rough) => { - let [y, v] = [base, 0]; - return Array.from({ length: w }, (_, x) => { - if (x % 2 === 0) { - v = (v + (next() - 0.5) * rough) * 0.86; - y += v + (base - y) * 0.06; - } - return Math.max(Math.round(Math.min(Math.max(y, base - amp), base + amp)), 0); - }); - }; - const layers = [ - [p.far, horizon - h * 0.1, h * 0.12, 3.2, null], - [p.mid, horizon + h * 0.02, h * 0.09, 2.6, null], - [p.near, horizon + h * 0.18, h * 0.07, 2.2, p.top], - ]; - let near = []; - for (const [c, base, amp, rough, top] of layers) { - const hs = ridge(base, amp, rough); - hs.forEach((y, x) => { - for (let yy = Math.min(y, h); yy < h; yy++) out[yy * w + x] = c; - if (top !== null && y < h) out[y * w + x] = top; - }); - near = hs; - } - for (let k = 0; k < Math.floor(w / 26); k++) { - const tx = 4 + Math.floor(next() * (w - 10)); - const ty = near[tx]; - rect(tx - 2, ty - 8, 5, 5, p.trees); - rect(tx - 1, ty - 10, 3, 2, p.trees); - rect(tx, ty - 3, 1, 3, p.trees); - } - return out; -} - -const png = (texels, w, h, file) => - sharp(Buffer.from(texels.flatMap(channels)), { raw: { width: w, height: h, channels: 3 } }) - .png({ compressionLevel: 9, palette: true }) - .toFile(file); - -// The page colours and the stone's shades come from crates/horizon-theme, like tokens.css. -export async function writeArt(media) { - const theme = await readFile(join(repo, "crates/horizon-theme/src/lib.rs"), "utf8"); - const page = (name) => parseInt(theme.match(new RegExp(`pub const ${name}: Blocks = Blocks \\{\\s*page: 0x([0-9a-f]{6})`))[1], 16); - const shades = JSON.parse(`[${theme.match(/TILE_SHADES: \[i16; 5\] = \[([^\]]*)\]/)[1]}]`); - const texels = Number(theme.match(/TILE_TEXELS: usize = (\d+)/)[1]) * 4; - await png(stone(page("BLOCKS_DARK"), shades, texels), texels, texels, join(media, "stone-dark.png")); - await png(stone(page("BLOCKS_LIGHT"), shades, texels), texels, texels, join(media, "stone-light.png")); - const [w, h] = [240, 72]; - await png(scene(SUNSET, 3, w, h), w, h, join(media, "scene-dark.png")); - await png(scene(DAWN, 3, w, h), w, h, join(media, "scene-light.png")); -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - await writeArt(join(site, "public", "media")); - console.log("pixel art written to public/media/"); -} diff --git a/website/scripts/assets.mjs b/website/scripts/assets.mjs deleted file mode 100644 index 92b197e7e..000000000 --- a/website/scripts/assets.mjs +++ /dev/null @@ -1,60 +0,0 @@ -// Copies the launcher's fonts and the repo's screenshots into public/, draws the pixel art, -// converted for the web (woff2, webp in two widths). Run it after any of them change: -// `npm run assets`. Its output is committed, because Railway builds only website/. -import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import sharp from "sharp"; -import wawoff2 from "wawoff2"; -import { writeArt } from "./art.mjs"; - -const site = join(dirname(fileURLToPath(import.meta.url)), ".."); -const repo = join(site, ".."); -const pub = join(site, "public"); - -const fonts = [ - "Geist-Regular.ttf", - "Geist-Medium.ttf", - "Geist-SemiBold.ttf", - "GeistMono-Regular.ttf", - "PixelifySans-Medium.ttf", - "PixelifySans-SemiBold.ttf", - "PixelifySans-Bold.ttf", -]; - -// [source, name]; every image becomes media/.webp and media/-800.webp. -export const images = [ - ["docs/media/sunset.jpg", "sunset"], - ["docs/media/village.jpg", "village"], - ["docs/media/survival.jpg", "survival"], - ["docs/media/nether.jpg", "nether"], - ["docs/media/night.jpg", "night"], - ["docs/media/underwater.jpg", "underwater"], - ["docs/media/settings-simple.png", "settings"], - ["docs/media/launcher-play.png", "launcher"], -]; - -await mkdir(join(pub, "fonts"), { recursive: true }); -await mkdir(join(pub, "media"), { recursive: true }); - -for (const font of fonts) { - const ttf = await readFile(join(repo, "crates/horizon-theme/fonts", font)); - await writeFile(join(pub, "fonts", font.replace(".ttf", ".woff2")), await wawoff2.compress(ttf)); -} -for (const licence of ["OFL-Geist.txt", "OFL-PixelifySans.txt"]) { - await copyFile(join(repo, "crates/horizon-theme/fonts", licence), join(pub, "fonts", licence)); -} - -for (const [source, name] of images) { - const input = join(repo, source); - await sharp(input).resize({ width: 1600, withoutEnlargement: true }).webp({ quality: 82 }).toFile(join(pub, "media", `${name}.webp`)); - await sharp(input).resize({ width: 800 }).webp({ quality: 78 }).toFile(join(pub, "media", `${name}-800.webp`)); -} - -// The stone tile and the hero's landscape, drawn in code (scripts/art.mjs). -await writeArt(join(pub, "media")); - -// The icons (favicon.svg, favicon-32.png, apple-touch-icon.png) come from Horizon's mark: -// `cargo run -p xtask -- icons` writes them with the rest of the brand (docs/media/brand). - -console.log("assets written to public/"); diff --git a/website/scripts/benchmarks.mjs b/website/scripts/benchmarks.mjs deleted file mode 100644 index 724834ea7..000000000 --- a/website/scripts/benchmarks.mjs +++ /dev/null @@ -1,240 +0,0 @@ -// Benchmark results for the site: benchmark reports (docs/benchmark-report.md) in, the summary the Benchmarks page -// reads out (public/benchmarks.json, and the telemetry server's GET /v1/benchmarks/summary in the same shape). -// -// node scripts/benchmarks.mjs --ours fleet.json [--telemetry items.jsonl] [-o public/benchmarks.json] -// -// --ours lists our own runs (tools/perf_bench.py reports, Horizon and vanilla, one machine each); --telemetry holds -// players' `benchmark` items as the server stores them ({ install_id, day, version, data: } per line). -// Players' results are grouped by GPU, CPU, render distance and game, and a group is published only with at least -// MIN_INSTALLS installs, so nobody can be picked out. install_id is used to count installs and to weigh each install -// once, and never leaves this script; neither do the day, OS version, driver, the exact RAM and VRAM, or the screen. -// A group also says how its installs split by hardware class (RAM, VRAM, drive, OS, integrated or discrete GPU), -// each class only with at least MIN_INSTALLS installs. The classes are the server's (src/classes.rs). -import { readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -export const FORMAT = "horizon-benchmark-summary"; -export const MIN_INSTALLS = 3; -export const SCENES = ["village", "forest", "ocean", "mountain"]; -export const FIELDS = ["avg_fps", "low_1pct_fps", "p95_ms", "world_ms", "chunk_load_ms", "rss_peak_mb", "rss_steady_mb"]; - -const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null); -const finite = (x) => typeof x === "number" && Number.isFinite(x); -export function median(xs) { - const v = xs.filter(finite).sort((a, b) => a - b); - if (!v.length) return null; - const m = v.length >> 1; - return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2; -} -// fps to integers, milliseconds to a tenth under 10 and to integers above, megabytes to integers. -export function round(field, x) { - if (!finite(x)) return null; - if (field.endsWith("_ms")) return x < 10 ? Math.round(x * 10) / 10 : Math.round(x); - return Math.round(x); -} -const trim = (s) => (typeof s === "string" ? s.replace(/\(R\)|\(TM\)|\(tm\)/g, "").replace(/\s+/g, " ").trim() : ""); -const gameOf = (app) => (app?.name === "horizon-client" ? "horizon" : app?.name === "minecraft" && !(app.mods || []).some((m) => m.name !== "fabric-loader") ? "vanilla" : null); - -// --- Hardware classes: coarse, so that a class holds many machines --------------------------- - -export const DIMENSIONS = ["ram", "vram", "storage", "os", "gpu_kind"]; -export function ramClass(gb) { - if (!(gb > 0)) return null; - return gb <= 5 ? "4 GB or less" : gb <= 10 ? "8 GB" : gb <= 20 ? "16 GB" : gb <= 40 ? "32 GB" : "64 GB or more"; -} -export function gpuKindClass(s) { - return s === "integrated" || s === "IntegratedGpu" ? "integrated" : s === "discrete" || s === "DiscreteGpu" ? "discrete" : null; -} -// A GPU's own memory in MiB; an integrated GPU shares the system's. -export function vramClass(mb, kind) { - if (gpuKindClass(kind) === "integrated") return "shared"; - if (!(mb > 0)) return null; - for (const [max, name] of [[2560, "2 GB or less"], [4608, "4 GB"], [6656, "6 GB"], [8704, "8 GB"], [12800, "12 GB"], [16896, "16 GB"]]) if (mb <= max) return name; - return "more than 16 GB"; -} -const oneOf = (list) => (s) => (list.includes(s) ? s : null); -export const storageClass = oneOf(["nvme", "ssd", "hdd"]); -export const osClass = oneOf(["linux", "macos", "windows"]); -// A report's classes, in DIMENSIONS order. -export function classesOf(report) { - const hw = report?.hardware || {}; - const kind = hw.gpu?.kind; - return [ramClass(finite(hw.ram_mb) ? hw.ram_mb / 1024 : 0), vramClass(hw.gpu?.vram_mb, kind), storageClass(hw.storage), osClass(hw.os), gpuKindClass(kind)]; -} -// Per dimension, the classes with at least minInstalls installs, most first; dimensions without one are left out. -export function breakdowns(classes, minInstalls) { - const out = {}; - DIMENSIONS.forEach((dim, i) => { - const cells = [...classes[i]].map(([c, set]) => ({ class: c, installs: set.size })).filter((c) => c.installs >= minInstalls); - cells.sort((a, b) => b.installs - a.installs || (a.class < b.class ? -1 : a.class > b.class ? 1 : 0)); - if (cells.length) out[dim] = cells; - }); - return out; -} - -// Why a report can't be compared with the others, or null when it can: the standard flight over the four scenes, -// VSync off, no frame cap, drawn on a screen. -export function rejection(report) { - if (!report || report.format !== "horizon-benchmark-report" || report.version !== 1) return "not a version 1 benchmark report"; - if (report.kind !== "game") return "not a game run"; - if (!gameOf(report.app)) return "not Horizon or plain vanilla"; - const c = report.conditions || {}; - if (!(c.scenario === "standard" || c.scenario === "flight" || String(c.scenario || "").startsWith("perf_bench:"))) return "not the standard flight"; - if (c.vsync !== false) return "VSync on or unknown"; - if (c.fps_cap != null) return "frame rate capped"; - if (c.offscreen === true) return "offscreen"; - if (!finite(c.render_distance)) return "no render distance"; - const phases = new Set((report.frames || []).map((f) => f.phase)); - if (!SCENES.every((s) => phases.has(s))) return "not every scene"; - if (!trim(report.hardware?.gpu?.name) || !trim(report.hardware?.cpu)) return "no GPU or CPU name"; - return null; -} - -// One report's figures: frame figures averaged over every flight (scene and repeat), the rest from its metrics. -export function figures(report) { - const frames = report.frames || []; - const m = report.metrics || {}; - const metric = (name) => (finite(m[name]?.mean) ? m[name].mean : null); - const loads = Object.keys(m).filter((k) => /^chunks\.[^.]+\.load_ms$/.test(k)).map(metric).filter(finite); - const avg = (key) => mean(frames.map((f) => f[key]).filter(finite)); - return { - avg_fps: avg("avg_fps"), - low_1pct_fps: avg("low_1pct_fps"), - p95_ms: avg("p95_ms"), - world_ms: metric("startup.world_ms"), - chunk_load_ms: mean(loads), - rss_peak_mb: metric("memory.rss_peak_mb"), - rss_steady_mb: metric("memory.rss_steady_mb"), - cpu_p50_ms: avg("cpu_p50_ms"), - gpu_p50_ms: avg("gpu_p50_ms"), - }; -} - -const roundAll = (fig, fields) => Object.fromEntries(fields.map((f) => [f, round(f, fig[f])])); - -// Players' items -> groups of at least minInstalls installs. Each install counts once: its median first, then the -// median across installs. -export function aggregatePlayers(items, { minInstalls = MIN_INSTALLS } = {}) { - const groups = new Map(); - for (const item of items) { - const report = item?.data; - if (!item?.install_id || rejection(report)) continue; - const key = JSON.stringify([trim(report.hardware.gpu.name), trim(report.hardware.cpu), report.conditions.render_distance, gameOf(report.app)]); - if (!groups.has(key)) groups.set(key, { installs: new Map(), reports: 0, versions: new Set(), classes: DIMENSIONS.map(() => new Map()) }); - const g = groups.get(key); - g.reports++; - if (item.version) g.versions.add(item.version); - if (!g.installs.has(item.install_id)) g.installs.set(item.install_id, []); - g.installs.get(item.install_id).push(figures(report)); - classesOf(report).forEach((c, i) => { - if (!c) return; - if (!g.classes[i].has(c)) g.classes[i].set(c, new Set()); - g.classes[i].get(c).add(item.install_id); - }); - } - const out = []; - for (const [key, g] of groups) { - if (g.installs.size < minInstalls) continue; - const [gpu, cpu, render_distance, game] = JSON.parse(key); - const perInstall = [...g.installs.values()].map((runs) => Object.fromEntries(FIELDS.map((f) => [f, median(runs.map((r) => r[f]))]))); - const medians = Object.fromEntries(FIELDS.map((f) => [f, round(f, median(perInstall.map((r) => r[f])))])); - out.push({ gpu, cpu, render_distance, game, source: "players", installs: g.installs.size, reports: g.reports, versions: [...g.versions].sort(byVersionDesc).slice(0, 3), medians, breakdowns: breakdowns(g.classes, minInstalls) }); - } - // Most installs first, then by GPU, CPU, render distance and game, compared as plain strings (as the server does). - const cmp = (x, y) => (x < y ? -1 : x > y ? 1 : 0); - return out.sort((a, b) => b.installs - a.installs || cmp(a.gpu, b.gpu) || cmp(a.cpu, b.cpu) || a.render_distance - b.render_distance || cmp(a.game, b.game)); -} - -// "v0.2.10" after "v0.2.9", dated tags by their numbers; good enough to list the newest three. -function byVersionDesc(a, b) { - const n = (v) => (v.match(/\d+/g) || []).map(Number); - const [x, y] = [n(a), n(b)]; - for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? -1) !== (y[i] ?? -1)) return (y[i] ?? -1) - (x[i] ?? -1); - return 0; -} - -// Our own runs: one row per machine, game and render distance, with what was measured and how. An entry holds one -// report (its runs are the report's repeats) or several (one game each); figures are medians over the reports. -export function ourRows(runs) { - const rows = []; - for (const { report, reports, machine, note, version, capped_fps } of runs) { - const list = reports || [report]; - for (const r of list) { - const why = rejection(r); - if (why) throw new Error(`${machine}: ${r?.label || "report"} can't be published: ${why}`); - } - const [first] = list; - const c = first.conditions; - if (list.some((r) => r.conditions.render_distance !== c.render_distance || gameOf(r.app) !== gameOf(first.app) || r.app.version !== first.app.version)) - throw new Error(`${machine}: reports of different games, versions or render distances in one entry`); - const figs = list.map(figures); - const fig = Object.fromEntries(Object.keys(figs[0]).map((k) => [k, median(figs.map((f) => f[k]))])); - const scenes = Object.fromEntries( - SCENES.map((s) => { - const fs = list.flatMap((r) => r.frames.filter((f) => f.phase === s)); - const loads = list.map((r) => r.metrics?.[`chunks.${s}.load_ms`]?.mean); - return [s, { avg_fps: round("avg_fps", mean(fs.map((f) => f.avg_fps))), low_1pct_fps: round("low_1pct_fps", mean(fs.map((f) => f.low_1pct_fps))), chunk_load_ms: round("chunk_load_ms", median(loads)) }]; - }), - ); - rows.push({ - gpu: trim(first.hardware.gpu.name), - cpu: trim(first.hardware.cpu), - render_distance: c.render_distance, - game: gameOf(first.app), - source: "us", - machine, - measured: list.map((r) => (r.created || "").slice(0, 10)).sort().at(-1), - // A release build reports its crate version; the entry can name the release it was. - version: version || first.app.version, - backend: first.hardware.gpu.backend || null, - os: first.hardware.os || null, - window: c.window || null, - repeats: list.reduce((n, r) => n + (r.conditions.repeats || 1), 0), - load_avg_1m: Math.max(...list.map((r) => r.conditions.load_avg_1m ?? 0)), - ...(note ? { note } : {}), - // The frame rate the display held this game to (a present mode VSync off doesn't lift): its average is a floor. - ...(capped_fps ? { capped_fps } : {}), - // A split of 0 means the backend gave no timing (Metal has no GPU timer here): unknown, not free. - medians: { ...roundAll(fig, FIELDS), cpu_p50_ms: fig.cpu_p50_ms > 0 ? round("cpu_p50_ms", fig.cpu_p50_ms) : null, gpu_p50_ms: fig.gpu_p50_ms > 0 ? round("gpu_p50_ms", fig.gpu_p50_ms) : null }, - scenes, - }); - } - return rows; -} - -export function summary({ ours = [], players = [], generated = new Date() } = {}) { - return { format: FORMAT, version: 1, generated: generated.toISOString().replace(/\.\d+Z$/, "Z"), min_installs: MIN_INSTALLS, groups: [...ours, ...players] }; -} - -// --- The command line ---------------------------------------------------------------------- - -async function main(argv) { - const arg = (name) => { - const i = argv.indexOf(name); - return i >= 0 ? argv[i + 1] : null; - }; - const site = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - const out = arg("-o") || resolve(site, "public/benchmarks.json"); - let ours = []; - if (arg("--ours")) { - // [{ "machine": "Mac mini", "reports": ["path/to/report.json", ...], "version": "v…", "note": "..." }], paths - // relative to the list; "version" names the release when the report only has the crate version. - const listPath = resolve(arg("--ours")); - const list = JSON.parse(await readFile(listPath, "utf8")); - const load = async (path) => JSON.parse(await readFile(resolve(dirname(listPath), path), "utf8")); - const runs = []; - for (const entry of list) runs.push({ ...entry, reports: await Promise.all((entry.reports || [entry.report]).map(load)), report: undefined }); - ours = ourRows(runs); - } - let players = []; - if (arg("--telemetry")) { - const text = await readFile(resolve(arg("--telemetry")), "utf8"); - players = aggregatePlayers(text.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l))); - } - const result = summary({ ours, players }); - await writeFile(out, JSON.stringify(result, null, 1) + "\n"); - console.log(`${out}: ${ours.length} rows measured by us, ${players.length} player groups`); -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(process.argv.slice(2)); diff --git a/website/scripts/benchmarks.test.mjs b/website/scripts/benchmarks.test.mjs deleted file mode 100644 index 87aec484b..000000000 --- a/website/scripts/benchmarks.test.mjs +++ /dev/null @@ -1,91 +0,0 @@ -// node --test (npm test in website/). The fixtures are shared with the telemetry server's summary test. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { aggregatePlayers, median, osClass, ourRows, ramClass, rejection, round, storageClass, summary, vramClass, gpuKindClass } from "./benchmarks.mjs"; - -const fixture = (name) => new URL(`./fixtures/benchmarks/${name}`, import.meta.url); -const items = readFileSync(fixture("items.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); -const expected = JSON.parse(readFileSync(fixture("expected.json"), "utf8")); - -test("players' items give the expected groups", () => { - const s = summary({ players: aggregatePlayers(items) }); - assert.equal(s.format, "horizon-benchmark-summary"); - assert.equal(s.min_installs, expected.min_installs); - assert.deepEqual(s.groups, expected.groups); -}); - -test("nothing that identifies an install is published", () => { - const text = JSON.stringify(aggregatePlayers(items, { minInstalls: 1 })); - for (const secret of ["a1", "a2", "install_id", "secret-os-version", "secret-driver", "20394", "ram_mb", "2560", "16376", "32768", "vram_mb"]) assert.ok(!text.includes(secret), secret); -}); - -test("a group needs three installs, not three runs", () => { - const one = items.filter((i) => i.install_id === "a1"); - assert.equal(aggregatePlayers([...one, ...one, ...one]).length, 0); - assert.equal(aggregatePlayers(items, { minInstalls: 2 }).length, 2); -}); - -test("hardware classes are coarse, as the server's", () => { - assert.equal(ramClass(15.6), "16 GB"); - assert.equal(ramClass(31), "32 GB"); - assert.equal(ramClass(64), "64 GB or more"); - assert.equal(ramClass(0), null); - assert.equal(vramClass(16376, "discrete"), "16 GB"); - assert.equal(vramClass(8192, "DiscreteGpu"), "8 GB"); - assert.equal(vramClass(0, "integrated"), "shared"); - assert.equal(vramClass(0, ""), null); - assert.equal(storageClass("nvme"), "nvme"); - assert.equal(storageClass("tape"), null); - assert.equal(osClass("windows"), "windows"); - assert.equal(gpuKindClass("virtual"), null); -}); - -test("a breakdown cell needs three installs too", () => { - const [group] = aggregatePlayers(items); - assert.equal(group.breakdowns.ram, undefined); // 32 GB has two installs, 16 GB one - assert.equal(group.breakdowns.storage, undefined); - const [all] = aggregatePlayers(items, { minInstalls: 1 }); - assert.deepEqual(all.breakdowns.ram, [{ class: "32 GB", installs: 2 }, { class: "16 GB", installs: 1 }]); - assert.deepEqual(all.breakdowns.storage, [{ class: "nvme", installs: 2 }, { class: "ssd", installs: 1 }]); -}); - -test("only comparable runs count", () => { - const reasons = items.map((i) => rejection(i.data)); - assert.deepEqual(reasons.filter(Boolean), ["VSync on or unknown", "offscreen", "frame rate capped", "not every scene", "not the standard flight", "not a version 1 benchmark report"]); -}); - -test("medians and rounding", () => { - assert.equal(median([3, 1, 2]), 2); - assert.equal(median([4, 1, 2, 3]), 2.5); - assert.equal(median([null, NaN]), null); - assert.equal(round("p95_ms", 3.46), 3.5); - assert.equal(round("world_ms", 4512.4), 4512); - assert.equal(round("avg_fps", 99.5), 100); -}); - -test("our runs keep the machine and how it was measured", () => { - const [row] = ourRows([{ machine: "Desktop", report: items[0].data }]); - assert.equal(row.source, "us"); - assert.equal(row.machine, "Desktop"); - assert.equal(row.game, "horizon"); - assert.equal(row.medians.avg_fps, 415); - assert.equal(row.scenes.ocean.avg_fps, 420); - assert.throws(() => ourRows([{ machine: "x", report: items[4].data }]), /VSync/); - // Several one-game reports: medians over them. - const [both] = ourRows([{ machine: "Desktop", reports: [items[0].data, items[1].data] }]); - assert.equal(both.medians.avg_fps, 515); - assert.equal(both.repeats, 2); - assert.throws(() => ourRows([{ machine: "x", reports: [items[0].data, items[9].data] }]), /different/); -}); - -test("a display cap on our run is kept, and the comparison says at least", async () => { - const [row] = ourRows([{ machine: "Mac", report: items[0].data, capped_fps: 144, version: "v1" }]); - assert.equal(row.capped_fps, 144); - assert.equal(row.version, "v1"); - const { compareRows, METRICS } = await import("../src/bench.js"); - const fps = METRICS[0]; - const h = { capped_fps: 144, medians: { avg_fps: 144 } }; - assert.equal(compareRows(fps, h, { medians: { avg_fps: 120 } }).text, "at least 1.2x faster"); - assert.equal(compareRows(fps, h, { medians: { avg_fps: 200 } }).wins, "none"); -}); diff --git a/website/scripts/shots.mjs b/website/scripts/shots.mjs deleted file mode 100644 index c0b86f40d..000000000 --- a/website/scripts/shots.mjs +++ /dev/null @@ -1,99 +0,0 @@ -// Full-page screenshots of the built site at every width that matters, plus the social -// preview image. Run `npm run build` first; `npm run shots` serves dist/ and writes -// shots/*.png (gitignored: full-page PNGs are too heavy for the repo), 390-menu.png (the open -// menu) and 1440-hero.png, and public/og.png (rebuild after). It also checks each width for -// horizontal scroll, small tap targets and line lengths. `SHOTS=dir` writes them elsewhere. -import { chromium } from "playwright"; -import { spawn } from "node:child_process"; -import { mkdir } from "node:fs/promises"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const site = join(dirname(fileURLToPath(import.meta.url)), ".."); -const port = 4199; -const url = `http://localhost:${port}/`; -const server = spawn(process.execPath, [join(site, "serve.mjs")], { env: { ...process.env, PORT: String(port) }, stdio: "ignore" }); -await new Promise((r) => setTimeout(r, 400)); - -const widths = [360, 390, 430, 768, 1024, 1180, 1250, 1280, 1366, 1440, 1680, 1920]; -const sizes = [...widths.map((w) => [w, w < 900 ? 800 : 900]), [1250, 700]]; -const only = process.argv.slice(2); -const out = process.env.SHOTS ? resolve(process.env.SHOTS) : join(site, "shots"); - -await mkdir(out, { recursive: true }); -const browser = await chromium.launch(); -let problems = 0; - -for (const [width, height] of sizes) { - const name = height === 700 ? `${width}x${height}` : `${width}`; - if (only.length && !only.includes(name)) continue; - const touch = width < 900; - const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1, hasTouch: touch, isMobile: touch }); - await page.goto(url, { waitUntil: "networkidle" }); - // Reveal every section, as scrolling would, and let the fades finish. - await page.evaluate(async () => { - for (let y = 0; y < document.body.scrollHeight; y += 400) { - scrollTo({ top: y, behavior: "instant" }); - await new Promise((r) => setTimeout(r, 30)); - } - scrollTo({ top: 0, behavior: "instant" }); - }); - await page.waitForTimeout(800); - const report = await page.evaluate((touch) => { - const out = []; - const doc = document.documentElement; - if (doc.scrollWidth > innerWidth) out.push(`horizontal scroll: ${doc.scrollWidth} > ${innerWidth}`); - for (const el of document.querySelectorAll("body *")) { - const r = el.getBoundingClientRect(); - if (r.width && r.right > innerWidth + 1 && !el.closest(".lightbox, .menu, .hero-media, .footer-word")) { - out.push(`overflows: ${el.tagName.toLowerCase()}.${el.className} right ${Math.round(r.right)}`); - } - } - if (touch) { - for (const el of document.querySelectorAll("a, button, summary")) { - if (el.closest(".menu, .lightbox, .skip") || el.closest(".prose, .footer-cols, .fact p, .system-detail, .download-meta, .meta")) continue; - const r = el.getBoundingClientRect(); - if (r.width && r.height < 44) out.push(`small target: ${el.textContent.trim().slice(0, 30)} (${Math.round(r.height)}px)`); - } - } - // Characters per line of the longest running-text paragraphs. - const lengths = [...document.querySelectorAll(".prose p, .lede, .hero-line, .fact p, .features p, .system-detail, .count-label, .footer-cols p")] - .filter((p) => p.textContent.length > 160) - .map((p) => { - const lh = parseFloat(getComputedStyle(p).lineHeight); - const n = Math.round(p.textContent.length / Math.max(1, Math.round(p.getBoundingClientRect().height / lh))); - return [n, `${p.parentElement.id || p.parentElement.className}>${p.className || p.tagName}`]; - }) - .sort((a, b) => a[0] - b[0]); - const [lo, hi] = [lengths[0], lengths[lengths.length - 1]]; - out.push(`chars per line: ${lo[0]} (${lo[1]})–${hi[0]} (${hi[1]})`); - return out.slice(0, 12); - }, touch); - const issues = report.filter((l) => !l.startsWith("chars")); - problems += issues.length; - console.log(`${name}: ${issues.length ? issues.join("; ") : "ok"} · ${report.find((l) => l.startsWith("chars"))}`); - await page.screenshot({ path: join(out, `${name}.png`), fullPage: height !== 700 }); - if (width === 1440) await page.screenshot({ path: join(out, "1440-hero.png") }); - if (width === 390) { - await page.click(".menu-button"); - await page.waitForTimeout(800); - await page.screenshot({ path: join(out, "390-menu.png") }); - } - await page.close(); -} - -if (!only.length || only.includes("og")) { - const page = await browser.newPage({ viewport: { width: 1200, height: 630 } }); - await page.goto(`${url}og.html`, { waitUntil: "networkidle" }); - await page.waitForTimeout(700); - await page.screenshot({ path: join(site, "public/og.png") }); - await page.close(); -} - -await browser.close(); -server.kill(); - -if (problems) { - console.log(`${problems} problems`); - process.exitCode = 1; -} diff --git a/website/serve.mjs b/website/serve.mjs deleted file mode 100644 index 553a3ff9b..000000000 --- a/website/serve.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// A dependency-free static server for dist/, for local checks (`npm run dev`). -// Railway uses Caddy instead (see Dockerfile). -import { createServer } from "node:http"; -import { readFile, stat } from "node:fs/promises"; -import { join, extname, normalize } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = join(fileURLToPath(new URL(".", import.meta.url)), "dist"); -const types = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".webp": "image/webp", ".png": "image/png", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".json": "application/json", ".xml": "application/xml" }; -const port = Number(process.env.PORT) || 4173; - -createServer(async (req, res) => { - let path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)).replace(/^(\.\.[/\\])+/, ""); - if (path === "/docs/api/") { - res.writeHead(301, { Location: "/docs/api" }); - return res.end(); - } - if (path.endsWith("/")) path += "index.html"; - // The server guide is part of the docs now, as the Caddyfile redirects it. - if (path === "/server" || path === "/server.html") { - res.writeHead(301, { Location: "/docs/hosting" }); - return res.end(); - } - // The roadmap page became the hub's Roadmap tab. - if (path === "/roadmap") { - res.writeHead(301, { Location: "/hub#roadmap" }); - return res.end(); - } - if (path === "/docs") path = "/docs/index.html"; - // /privacy, /docs/ and /docs/api/ are .html files, as the Caddyfile rewrites them. - if (!extname(path)) path += ".html"; - let file = join(root, path); - let status = 200; - try { - if (!(await stat(file)).isFile()) throw 0; - } catch { - file = join(root, "404.html"); - status = 404; - } - res.writeHead(status, { "Content-Type": types[extname(file)] || "application/octet-stream" }); - res.end(await readFile(file)); -}).listen(port, () => console.log(`http://localhost:${port}`)); diff --git a/website/src/404.html b/website/src/404.html deleted file mode 100644 index 3ac1e017a..000000000 --- a/website/src/404.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Not found · Horizon - - - - - - - - - -
-
-

404Not found

-

Not here.

-

This page doesn't exist. The rest of Horizon is one page away.

- -

Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

-
-
- - - diff --git a/website/src/bench.js b/website/src/bench.js deleted file mode 100644 index 40185c79d..000000000 --- a/website/src/bench.js +++ /dev/null @@ -1,167 +0,0 @@ -// The Benchmarks page's tables, from the summary (public/benchmarks.json, or the telemetry server's -// GET /v1/benchmarks/summary in the same shape). Shared by build.mjs (baked) and benchmarks-page.js (live). -import { escape } from "./markdown.js"; - -export const METRICS = [ - { key: "avg_fps", label: "Average FPS", kind: "fps", better: "high" }, - { key: "low_1pct_fps", label: "1% low FPS", kind: "fps", better: "high", hint: "the slowest 1% of frames, as FPS" }, - { key: "p95_ms", label: "Frame time, 95th percentile", kind: "ms", better: "low" }, - { key: "world_ms", label: "Start to in-world", kind: "ms", better: "low", hint: "process start to the spawn area loaded and drawn" }, - { key: "chunk_load_ms", label: "Chunk load after a teleport", kind: "ms", better: "low", hint: "the four scenes' average" }, - { key: "rss_steady_mb", label: "Memory while playing", kind: "mb", better: "low" }, - { key: "rss_peak_mb", label: "Memory, peak", kind: "mb", better: "low" }, -]; - -export function format(kind, x) { - if (x == null || !Number.isFinite(x)) return "–"; - if (kind === "fps") return x >= 100 ? Math.round(x).toLocaleString("en") : x.toFixed(x >= 10 ? 0 : 1); - if (kind === "ms") return x >= 1000 ? `${(x / 1000).toFixed(x >= 10000 ? 0 : 1)} s` : `${x < 10 ? x.toFixed(1) : Math.round(x)} ms`; - if (kind === "mb") return x >= 1000 ? `${(x / 1024).toFixed(1)} GB` : `${Math.round(x)} MB`; - return String(x); -} - -// "2.4x faster", "1.3x slower", "2.0x less" (memory), from Horizon's side. -export function compare(metric, horizon, java) { - if (horizon == null || java == null || !horizon || !java) return null; - const ratio = metric.better === "high" ? horizon / java : java / horizon; - if (Math.abs(ratio - 1) < 0.05) return { text: "about the same", wins: "tie" }; - const word = metric.kind === "mb" ? (ratio > 1 ? "less" : "more") : ratio > 1 ? "faster" : "slower"; - const r = ratio > 1 ? ratio : 1 / ratio; - return { text: `${r.toFixed(r >= 10 ? 0 : 1)}x ${word}`, wins: ratio > 1 ? "horizon" : "java" }; -} - -// The game's average frame rate sat at a display cap (`capped_fps`): the real one is at least that. -export const capped = (row) => Boolean(row?.capped_fps && row.medians?.avg_fps >= row.capped_fps * 0.97); - -// compare() between two rows, saying "at least" where Horizon's average sat at a cap. -export function compareRows(metric, h, j) { - const c = compare(metric, h?.medians?.[metric.key], j?.medians?.[metric.key]); - if (!c || metric.key !== "avg_fps" || !capped(h)) return c; - return c.wins === "horizon" ? { ...c, text: `at least ${c.text}` } : { text: "capped, can't tell", wins: "none" }; -} - -export const cell = (metric, row) => { - const v = format(metric.kind, row?.medians?.[metric.key]); - return metric.key === "avg_fps" && capped(row) ? `${v} (cap)` : v; -}; - -const machines = (groups) => { - const by = new Map(); - for (const g of groups.filter((g) => g.source === "us")) { - if (!by.has(g.machine)) by.set(g.machine, []); - by.get(g.machine).push(g); - } - return by; -}; - -const pick = (rows, game, rd) => rows.find((r) => r.game === game && r.render_distance === rd); - -// One machine we measured: Horizon and Java side by side at each render distance. -export function machineHtml(name, rows) { - const first = rows[0]; - const distances = [...new Set(rows.map((r) => r.render_distance))].sort((a, b) => a - b); - const horizon = rows.find((r) => r.game === "horizon"); - const java = rows.find((r) => r.game === "vanilla"); - const head = distances.map((rd) => `Render distance ${rd}`).join(""); - const sub = distances.map(() => `HorizonJavaHorizon against Java`).join(""); - const body = METRICS.map((m) => { - const cells = distances - .map((rd) => { - const h = pick(rows, "horizon", rd); - const j = pick(rows, "vanilla", rd); - const c = compareRows(m, h, j); - return `${cell(m, h)}${cell(m, j)}${c ? escape(c.text) : ""}`; - }) - .join(""); - return `${escape(m.label)}${m.hint ? `${escape(m.hint)}` : ""}${cells}`; - }).join(""); - const javaWins = METRICS.flatMap((m) => - distances.filter((rd) => compareRows(m, pick(rows, "horizon", rd), pick(rows, "vanilla", rd))?.wins === "java").map((rd) => `${m.label.toLowerCase()} at ${rd}`), - ); - // Per scene too: an average that Horizon wins can hide a scene where Java is faster. - const sceneName = { village: "the village", forest: "the forest", ocean: "the ocean", mountain: "the mountain" }; - for (const rd of distances) { - const h = pick(rows, "horizon", rd); - const j = pick(rows, "vanilla", rd); - for (const [scene, js] of Object.entries(j?.scenes || {})) { - const hs = h?.scenes?.[scene]; - if (!hs?.avg_fps || !js?.avg_fps || (h.capped_fps && hs.avg_fps >= h.capped_fps * 0.97)) continue; - if (js.avg_fps > hs.avg_fps * 1.05) javaWins.push(`average FPS over ${sceneName[scene] || scene} at ${rd} (${format("fps", js.avg_fps)} against ${format("fps", hs.avg_fps)})`); - } - } - const facts = [ - ["CPU", first.cpu], - ["GPU", first.gpu], - ["Horizon", horizon ? `${horizon.version} · ${horizon.backend || "?"}` : "not measured"], - ["Java", java ? `${java.version} (vanilla) · ${java.backend || "?"}` : "not measured"], - ["Measured", `${first.measured}${first.repeats ? ` · ${first.repeats} runs of each` : ""}${first.window ? ` · ${first.window.join("x")} window` : ""}`], - ]; - return `
-
-

Measured by us

-

${escape(name)}

-
${facts.map(([k, v]) => `
${k}
${escape(String(v))}
`).join("")}
- ${rows.find((r) => r.note) ? `

${escape(rows.find((r) => r.note).note)}

` : ""} -
-
- ${head}${sub} - ${body} -
- ${javaWins.length ? `

Where Java is ahead: ${escape(javaWins.join("; "))}.

` : `

Horizon is ahead on every figure here.

`} -
`; -} - -export function machinesHtml(summary) { - const by = machines(summary?.groups || []); - if (!by.size) return `

No measurements are published yet.

`; - return [...by].map(([name, rows]) => machineHtml(name, rows)).join("\n"); -} - -// The hardware breakdowns (the server's classes), in words. -export const DIMENSIONS = [ - { key: "ram", label: "Memory", word: (c) => `${c} RAM` }, - { key: "vram", label: "Graphics memory", word: (c) => (c === "shared" ? "shared graphics memory" : `${c} graphics memory`) }, - { key: "storage", label: "Drive", word: (c) => ({ nvme: "NVMe", ssd: "SSD", hdd: "hard disk" })[c] || c }, - { key: "os", label: "System", word: (c) => ({ linux: "Linux", macos: "macOS", windows: "Windows" })[c] || c }, - { key: "gpu_kind", label: "Graphics", word: (c) => (c === "integrated" ? "integrated GPU" : c === "discrete" ? "separate GPU" : c) }, -]; - -// A group's breakdowns in one line: "32 GB RAM (3) · NVMe (3) · Linux (3)". Empty without any. -export function breakdownLine(breakdowns) { - return DIMENSIONS.flatMap((d) => (breakdowns?.[d.key] || []).map((c) => `${d.word(c.class)} (${c.installs})`)).join(" · "); -} - -// GET /v1/aggregate's breakdowns: players' play sessions per hardware class. Empty without any. -export function hardwareHtml(aggregate) { - const b = aggregate?.breakdowns || {}; - const dims = DIMENSIONS.filter((d) => b[d.key]?.length); - if (!dims.length) return ""; - const rows = dims - .flatMap((d) => - b[d.key].map( - (c, i) => - `${i === 0 ? `${escape(d.label)}` : ""}${escape(d.word(c.class))}${c.installs}${Math.round(c.hours).toLocaleString("en")}${format("ms", c.p50_ms)}${format("ms", c.p99_ms)}`, - ), - ) - .join(""); - return `
- - ${rows} -
ByClassInstallsHours playedFrame time, medianFrame time, 99th percentile
`; -} - -// Players' groups: one row each, at least `min_installs` installs. -export function playersHtml(summary) { - const groups = (summary?.groups || []).filter((g) => g.source === "players"); - if (!groups.length) - return `

No player results yet. A GPU and CPU pair shows here once at least ${summary?.min_installs || 3} different installs have shared a benchmark with it.

`; - const rows = groups - .map( - (g) => `${escape(g.gpu)}${escape(g.cpu)}${g.render_distance}${g.game === "vanilla" ? "Java" : "Horizon"}${["avg_fps", "low_1pct_fps", "p95_ms", "world_ms", "rss_steady_mb"].map((k) => `${format(METRICS.find((m) => m.key === k).kind, g.medians?.[k])}`).join("")}From ${g.installs} players${breakdownLine(g.breakdowns) ? `${escape(breakdownLine(g.breakdowns))}` : ""}`, - ) - .join(""); - return `
- - ${rows} -
GPU and CPURender distanceGameAverage FPS1% lowp95To in-worldMemorySource
`; -} diff --git a/website/src/benchmarks-page.js b/website/src/benchmarks-page.js deleted file mode 100644 index 5d05848e3..000000000 --- a/website/src/benchmarks-page.js +++ /dev/null @@ -1,108 +0,0 @@ -// The Benchmarks page: players' results from the telemetry server (the baked summary when it doesn't answer) -// and the hardware picker (a measurement when there is one, else an estimate from src/estimate.js). -import { escape } from "./markdown.js"; -import { METRICS, cell, compareRows, format, hardwareHtml, playersHtml } from "./bench.js"; -import { estimate, measured, memoryNotes } from "./estimate.js"; -import { findCpu, findGpu } from "./hardware.js"; -import { setUpMenu, setUpTheme } from "./chrome.js"; - -const TELEMETRY = "https://telemetry.playhorizon.dev"; -const baked = JSON.parse(document.getElementById("bench-data").textContent); -let groups = baked.groups || []; - -async function live() { - try { - const res = await fetch(`${TELEMETRY}/v1/benchmarks/summary`, { signal: AbortSignal.timeout(3000) }); - if (!res.ok) throw new Error(String(res.status)); - const summary = await res.json(); - if (summary.format !== "horizon-benchmark-summary" || summary.version !== 1 || !Array.isArray(summary.groups)) throw new Error("format"); - // Our own rows stay the baked ones; players' come from the server. - const players = summary.groups.filter((g) => g.source === "players" && g.installs >= (summary.min_installs || 3)); - groups = [...groups.filter((g) => g.source === "us"), ...players]; - document.getElementById("players-table").innerHTML = playersHtml({ ...summary, groups: players }); - document.querySelector("[data-players-source]").textContent = `Live from the telemetry server, aggregated ${summary.generated.slice(0, 16).replace("T", " ")} UTC.`; - render(); - } catch { - // The baked results stay; the line under the heading already says when they were made. - } -} - -// Players' play sessions per hardware class (last 30 days); the section stays hidden until there are some. -async function hardware() { - try { - const res = await fetch(`${TELEMETRY}/v1/aggregate?days=30`, { signal: AbortSignal.timeout(3000) }); - if (!res.ok) return; - const html = hardwareHtml(await res.json()); - if (!html) return; - document.getElementById("players-hardware-table").innerHTML = html; - document.getElementById("players-hardware").hidden = false; - } catch { - // Nothing to show. - } -} - -const picker = document.getElementById("picker"); -const out = document.getElementById("pick-result"); - -const range = (kind, e) => { - if (!e) return "–"; - if (e.atLeast) return `at least ${format(kind, e.low)}`; - return e.low != null ? `${format(kind, e.low)} to ${format(kind, e.high)}` : format(kind, e.value); -}; - -function sourceBadge(row) { - return row.source === "us" ? `Measured by us · ${escape(row.machine)}` : `From ${row.installs} players`; -} - -function measuredTable(h, j) { - return `${METRICS.map((m) => { - const c = compareRows(m, h, j); - return ``; - }).join("")}
HorizonJavaHorizon against Java
${escape(m.label)}${cell(m, h)}${cell(m, j)}${c ? escape(c.text) : ""}
`; -} - -function estimateTable(h, j) { - return `${METRICS.map( - (m) => ``, - ).join("")}
Horizon (estimate)Java (estimate)
${escape(m.label)}${range(m.kind, h?.[m.key])}${range(m.kind, j?.[m.key])}
`; -} - -function render() { - const gpu = findGpu(picker.gpu.value); - const cpu = findCpu(picker.cpu.value); - const rd = Number(picker.rd.value); - const ramGb = Number(picker.ram.value) || null; - if (!gpu || !cpu) return; - // Horizon's and Java's notes, each once (the players' hardware is the same for both). - const notesHtml = (...lists) => { - const notes = [...new Set(lists.flat())]; - return notes.length ? `
    ${notes.map((n) => `
  • ${escape(n)}
  • `).join("")}
` : ""; - }; - const mh = measured(groups, gpu, cpu, rd, "horizon"); - const mj = measured(groups, gpu, cpu, rd, "vanilla"); - if (mh.length || mj.length) { - const h = mh[0]; - const j = mj[0]; - out.innerHTML = `

${[h, j].filter(Boolean).map(sourceBadge).join(" ")}

${measuredTable(h, j)}${notesHtml(...[h, j].filter(Boolean).map((row) => memoryNotes(row, gpu, ramGb)))}${ - h && h.source === "us" && h.measured ? `

Measured ${escape(h.measured)} with Horizon ${escape(h.version)}. The full table is under Measured by us.

` : "" - }`; - return; - } - const h = estimate(groups, gpu, cpu, rd, "horizon", { ramGb }); - const j = estimate(groups, gpu, cpu, rd, "vanilla", { ramGb }); - if (!h && !j) { - out.innerHTML = `

Nothing measured at render distance ${rd} to estimate from yet.

`; - return; - } - const base = (h || j).base; - const f = (h || j).factors; - const times = (x) => `${x >= 1 ? x.toFixed(x >= 10 ? 0 : 1) : x.toFixed(2)}x`; - out.innerHTML = `

Estimate From ${escape(base.source === "us" ? base.machine : `${base.gpu} with ${base.cpu}`)} (${escape(base.gpu)}, ${escape(base.cpu)}): GPU factor ${times(f.gpu)}, CPU factor ${times(f.cpu)}. How

${estimateTable(h, j)}${notesHtml(h?.notes || [], j?.notes || [])}

Ranges, not measurements: the real figure depends on drivers, the graphics API and cooling too. Memory is the measured machine's.

`; -} - -setUpTheme(); -setUpMenu(); -picker.addEventListener("change", render); -render(); -live(); -hardware(); diff --git a/website/src/benchmarks.html b/website/src/benchmarks.html deleted file mode 100644 index 41e270c5b..000000000 --- a/website/src/benchmarks.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - Benchmarks: Horizon against Java Edition · Horizon - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} -
-
-

BenchmarksHorizon against Java Edition 26.3

-

Same machine, same world, same flight.

-

We run Horizon and vanilla Minecraft: Java Edition 26.3 one after the other on the same computer: a fresh world from the same seed, the same settings, and the same four flights over a village, a forest, the ocean and a mountain. Every figure comes from the same code for both games. Where Java does better, the tables say so.

- -
-

Measured by us

-

The project's own machines, at render distance 12 (vanilla's default) and 32. VSync off, no frame cap, a 1280x720 window, simulation distance 12, everything else at vanilla's defaults. Each figure is the median over the runs; the frame figures are averaged over the four flights.

-
-{{MACHINES}} -
-
- -
-

Your hardware

-

Pick a graphics card, a processor and, if you know it, your memory. If we or at least three players have measured that pair, you see what was measured; otherwise an estimate from the nearest measured machine, as a range.

-
- - - -
Render distance - - -
-
-

The picker needs JavaScript; the tables above and the method below work without it.

-
- -
-

From players

-

Players who allow benchmark uploads in the launcher's data collection settings share the report of horizon-client --benchmark standard. Results are grouped by graphics card, processor and render distance, and a group shows only once at least 3 different installs are in it, so no one can be picked out. Only medians are published, with how the group's installs split by memory, graphics memory, drive and system in coarse classes (each class only with at least 3 installs too): no install ID, date, driver, exact memory or screen.

-

{{PLAYERS_SOURCE}}

-
-{{PLAYERS}} -
-
- - - -
-

How the estimates work

-
-

An estimate starts from the measured machine closest to the one you picked, then scales its figures with a small table of specifications we wrote ourselves (no benchmark database):

-
    -
  • GPU factor: the geometric mean of the two graphics cards' ratio of FP32 shader throughput (TFLOPS) and of memory bandwidth (GB/s), as their makers state them.
  • -
  • CPU factor: the ratio of the two processors' single-thread scores: top boost clock times a rough factor per chip generation (Zen 4 = 1).
  • -
  • Frame rate: when the measured run split each frame into CPU and GPU time, the slower side is scaled by its factor; otherwise by the geometric mean of both. Whatever the split, the real figure lies between the measured one scaled by the smaller and by the larger factor; we widen that by 15% for the roughness of the table, and show it as the range.
  • -
  • Load times are CPU work (generating, lighting and meshing chunks), scaled by the CPU factor. Memory is not scaled: it follows the world and the settings, not the speed.
  • -
  • Memory size: when players' results say how much memory and graphics memory their machines have, a measured group whose players have neither yours counts as further away, and the estimate says what they had. With your memory picked, it also says when the game's peak use is a large share of it.
  • -
-

Estimates ignore drivers, the graphics API, thermal limits and the operating system, which can matter as much as the chip. Treat them as a rough idea, and run horizon-client --benchmark standard on your own machine for the real figure.

-
-
- The specification table -
{{SPEC_TABLE}}
-
-
- -
-

How we measure

-
-

tools/perf_bench.py starts each game from a fresh world (seed 1234, creative), waits until every chunk around the spawn is loaded and drawn, then for each scene teleports, waits for the chunks, holds for 2 seconds and flies a straight 192-block path in 12 seconds. Every frame is recorded. Vanilla runs as players get it (your own client, the official launcher's Java options, Java 25), driven by a small development-only mod that only calls the game's own code; nothing of Minecraft is shipped. The full method and its caveats are in docs/benchmark.md, and every raw report is in the repository.

-

Measure your own computer with horizon-client --benchmark standard --render-distance 12: it writes a report to the benchmarks folder of the game folder.

-
-
-

Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

-
-
-{{SITE_FOOTER}} - - - diff --git a/website/src/chrome.js b/website/src/chrome.js deleted file mode 100644 index d11eb3370..000000000 --- a/website/src/chrome.js +++ /dev/null @@ -1,90 +0,0 @@ -// What every page with the site's top bar shares: the light and dark switch and the phone menu. -// Used by Home, Releases, Benchmarks and Docs. -const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; - -// The page follows the system; the toggle picks the other appearance and remembers it. -export function setUpTheme() { - const root = document.documentElement; - const button = document.querySelector(".theme-toggle"); - if (!button) return; - const label = button.querySelector("[data-theme-label]"); - const system = matchMedia("(prefers-color-scheme: dark)"); - const dark = () => (root.dataset.theme ? root.dataset.theme === "dark" : system.matches); - const show = () => (label.textContent = dark() ? "Light" : "Dark"); - button.addEventListener("click", () => { - root.dataset.theme = dark() ? "light" : "dark"; - try { - localStorage.setItem("theme", root.dataset.theme); - } catch {} - show(); - }); - system.addEventListener("change", show); - show(); -} - -// The phone menu: a full-screen sheet; the page behind it is inert while it is open. -export function setUpMenu() { - const bar = document.querySelector(".nav"); - const button = document.querySelector(".menu-button"); - const menu = document.getElementById("menu"); - const content = document.querySelector("[data-menu-content]") || document.getElementById("main"); - if (!bar || !button || !menu) return; - const label = button.querySelector(".menu-label"); - const items = [...menu.querySelectorAll("nav a")]; - items.forEach((a, i) => a.style.setProperty("transition-delay", `${60 + i * 35}ms`)); - let closing; - const setOpen = (open) => { - clearTimeout(closing); - button.setAttribute("aria-expanded", String(open)); - label.textContent = open ? "Close" : "Menu"; - bar.classList.toggle("is-menu", open); - content.inert = open; - document.body.style.overflow = open ? "hidden" : ""; - if (open) { - menu.hidden = false; - requestAnimationFrame(() => requestAnimationFrame(() => menu.classList.add("is-open"))); - items[0].focus({ preventScroll: true }); - } else { - menu.classList.remove("is-open"); - closing = setTimeout(() => (menu.hidden = true), reducedMotion ? 0 : 320); - } - }; - button.addEventListener("click", () => setOpen(button.getAttribute("aria-expanded") !== "true")); - items.forEach((a) => a.addEventListener("click", () => setOpen(false))); - addEventListener("keydown", (e) => { - if (e.key === "Escape" && button.getAttribute("aria-expanded") === "true") { - setOpen(false); - button.focus(); - } - }); - matchMedia("(min-width: 1280px)").addEventListener("change", (e) => e.matches && setOpen(false)); -} - -// GitHub answers, cached for ten minutes in the tab. -export async function cached(key, load) { - try { - const hit = JSON.parse(sessionStorage.getItem(key) || "null"); - if (hit && Date.now() - hit.at < 10 * 60 * 1000) return hit.value; - } catch {} - const value = await load(); - try { - sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), value })); - } catch {} - return value; -} - -export async function get(url, as = "json") { - const res = await fetch(url, { headers: as === "json" ? { Accept: "application/vnd.github+json" } : {} }); - if (!res.ok) throw new Error(`${res.status}`); - return as === "json" ? res.json() : res.text(); -} - -export const slim = (r) => ({ - tag_name: r.tag_name, - name: r.name, - published_at: r.published_at, - html_url: r.html_url, - prerelease: r.prerelease, - body: r.body || "", - assets: (r.assets || []).map((a) => ({ name: a.name, browser_download_url: a.browser_download_url, size: a.size })), -}); diff --git a/website/src/contributors.js b/website/src/contributors.js deleted file mode 100644 index 12bce9457..000000000 --- a/website/src/contributors.js +++ /dev/null @@ -1,39 +0,0 @@ -// The contributors: GitHub's list for the repository, without bots, the owner first. Shared by -// build.mjs (baked into the page) and main.js (refreshed from GitHub). -import { escape } from "./markdown.js"; - -const OWNER = "ludovic111"; -const AVATARS = "https://avatars.githubusercontent.com/"; - -// Reduces GitHub's contributor objects to what the page shows; bots (type Bot, or a login -// ending in [bot]) and anything without a login or a profile address are dropped. -export function contributorsFrom(list) { - if (!Array.isArray(list)) return []; - return list - .filter((c) => c && typeof c.login === "string" && c.login && c.type !== "Bot" && !/\[bot\]$/i.test(c.login)) - .map((c) => ({ - login: c.login, - html_url: typeof c.html_url === "string" ? c.html_url : `https://github.com/${c.login}`, - avatar_url: typeof c.avatar_url === "string" ? c.avatar_url : "", - contributions: Number(c.contributions) || 0, - })) - .sort((a, b) => (b.login === OWNER) - (a.login === OWNER) || b.contributions - a.contributions); -} - -// GitHub serves avatars at any size: 96 px is twice what the page draws, for sharp screens. -export function avatar(url) { - if (!url.startsWith(AVATARS)) return ""; - const u = new URL(url); - u.searchParams.set("s", "96"); - return u.href; -} - -export function contributorsHtml(list) { - return list - .map((c) => { - const src = avatar(c.avatar_url); - const count = `${c.contributions.toLocaleString("en-US")} ${c.contributions === 1 ? "commit" : "commits"}`; - return `
  • ${src ? `` : ``}${escape(c.login)}${count}
  • `; - }) - .join(""); -} diff --git a/website/src/docs-page.js b/website/src/docs-page.js deleted file mode 100644 index 99da35187..000000000 --- a/website/src/docs-page.js +++ /dev/null @@ -1,5 +0,0 @@ -// The Docs pages share the site's theme switch and phone menu. -import { setUpMenu, setUpTheme } from "./chrome.js"; - -setUpTheme(); -setUpMenu(); diff --git a/website/src/docs.html b/website/src/docs.html deleted file mode 100644 index 56ba89c09..000000000 --- a/website/src/docs.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - {{DOC_TITLE}} · Horizon docs - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} - -
    -
    - Docs menu - -
    - - -
    -

    {{DOC_GROUP}}

    -

    {{DOC_TITLE}}

    -

    {{DOC_LEDE}}

    -{{DOC_TOC}} -
    -{{DOC_BODY}} -
    - -

    Edit this page on GitHub · Spotted something wrong? Open an issue or ask on Discord.

    -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    - - - diff --git a/website/src/estimate.js b/website/src/estimate.js deleted file mode 100644 index ccd053cc0..000000000 --- a/website/src/estimate.js +++ /dev/null @@ -1,110 +0,0 @@ -// Estimates for hardware nobody has measured yet, from the nearest measured machine and the spec table -// (src/hardware.js). The method, which the Benchmarks page explains: -// -// - GPU factor: the geometric mean of the two GPUs' FP32 throughput ratio and memory bandwidth ratio. -// - CPU factor: the ratio of the two CPUs' single-thread scores (boost clock x generation factor). -// - Frames: when the measured run split each frame into CPU and GPU time, the slower of the two is scaled by its -// own factor; otherwise the frame rate is scaled by the geometric mean of both factors. Whatever the split, the -// true figure lies between the measured one scaled by the smaller and by the larger factor, widened by 15% for -// the roughness of the table: that is the range shown. -// - World and chunk load times are CPU work (generation, lighting, meshing): scaled by the CPU factor. -// - Memory is not scaled: it depends on the world and settings, not on speed. -// - Memory size: players' groups say how their installs split by RAM and by the GPU's own memory (breakdowns). A -// base whose players have neither the picked RAM nor the picked GPU's memory class counts as further away, and -// the estimate says what the base's players had, and when the game's peak memory is a large share of the RAM. -import { findCpu, findGpu, ramClass, singleThread, vramClass } from "./hardware.js"; - -const MARGIN = 0.15; - -export function factors(fromGpu, fromCpu, toGpu, toCpu) { - const gpu = Math.sqrt((toGpu.tflops / fromGpu.tflops) * (toGpu.gbps / fromGpu.gbps)); - const cpu = singleThread(toCpu) / singleThread(fromCpu); - return { gpu, cpu }; -} - -const distance = (f) => Math.abs(Math.log(f.gpu)) + Math.abs(Math.log(f.cpu)); -// Added to the distance per memory class (RAM, VRAM) the base's players don't have: about a 20% speed gap. -const MEMORY_MISMATCH = 0.2; - -// The classes a row's installs have in a breakdown, or null when it has none (our own rows, older summaries). -const classesIn = (row, dim) => (row.breakdowns?.[dim]?.length ? row.breakdowns[dim].map((c) => c.class) : null); - -function mismatch(row, gpu, ramGb) { - let d = 0; - const vram = classesIn(row, "vram"); - if (vram && !vram.includes(vramClass(gpu))) d += MEMORY_MISMATCH; - const ram = classesIn(row, "ram"); - if (ram && ramGb && !ram.includes(ramClass(ramGb))) d += MEMORY_MISMATCH; - return d; -} - -// What to say about memory: the base's players' RAM and VRAM when they differ from the pick, and the game's peak -// memory against the picked RAM. -export function memoryNotes(row, gpu, ramGb) { - const notes = []; - const list = (dim) => row.breakdowns[dim].map((c) => `${c.class} (${c.installs})`).join(", "); - const vram = classesIn(row, "vram"); - if (vram && gpu && !vram.includes(vramClass(gpu))) notes.push(`The measured players' graphics cards have ${list("vram")} of their own memory; this one has ${gpu.vram_gb ? `${gpu.vram_gb} GB` : "none (it shares the system's)"}.`); - const ram = classesIn(row, "ram"); - if (ram && ramGb && !ram.includes(ramClass(ramGb))) notes.push(`The measured players have ${list("ram")} of memory, not ${ramGb} GB.`); - const peak = row.medians?.rss_peak_mb; - if (ramGb && peak && peak / 1024 > ramGb * 0.4) - notes.push(`${row.game === "vanilla" ? "Java" : "Horizon"} used up to ${(peak / 1024).toFixed(1)} GB here: with ${ramGb} GB, close other programs, or the system may swap and stutter.`); - return notes; -} - -// The rows (benchmarks.json groups) usable as a base: a known GPU and CPU. -function known(rows) { - return rows.map((row) => ({ row, gpu: findGpu(row.gpu), cpu: findCpu(row.cpu) })).filter((r) => r.gpu && r.cpu); -} - -// The measured row for exactly this GPU and CPU, players' first when there are some (more installs), or null. -export function measured(rows, gpu, cpu, rd, game) { - const hits = known(rows).filter((r) => r.gpu === gpu && r.cpu === cpu && r.row.render_distance === rd && r.row.game === game); - hits.sort((a, b) => (b.row.installs || 0) - (a.row.installs || 0)); - return hits.map((r) => r.row); -} - -// The nearest measured machine for a target GPU and CPU, for one game and render distance. -export function nearest(rows, gpu, cpu, rd, game, { ramGb } = {}) { - let best = null; - for (const r of known(rows)) { - if (r.row.render_distance !== rd || r.row.game !== game) continue; - const f = factors(r.gpu, r.cpu, gpu, cpu); - const d = distance(f) + mismatch(r.row, gpu, ramGb); - if (!best || d < best.d) best = { ...r, f, d }; - } - return best; -} - -export function estimate(rows, gpu, cpu, rd, game, { ramGb } = {}) { - const base = nearest(rows, gpu, cpu, rd, game, { ramGb }); - if (!base) return null; - const m = base.row.medians; - const { gpu: g, cpu: c } = base.f; - const lo = Math.min(g, c) * (1 - MARGIN); - const hi = Math.max(g, c) * (1 + MARGIN); - let scale = Math.sqrt(g * c); - if (m.cpu_p50_ms && m.gpu_p50_ms) { - const worst = Math.max(m.cpu_p50_ms, m.gpu_p50_ms); - scale = worst / Math.max(m.cpu_p50_ms / c, m.gpu_p50_ms / g); - } - scale = Math.min(hi, Math.max(lo, scale)); - const fps = (x) => (x == null ? null : { value: x * scale, low: x * lo, high: x * hi }); - // A base held to its display's refresh rate gives only a floor for the average. - const avg = fps(m.avg_fps); - if (avg && base.row.capped_fps && m.avg_fps >= base.row.capped_fps * 0.97) Object.assign(avg, { value: null, high: null, atLeast: true }); - const time = (x, f) => (x == null ? null : { value: x / f, low: x / (f * (1 + MARGIN)), high: x / (f * (1 - MARGIN)) }); - return { - base: base.row, - factors: base.f, - avg_fps: avg, - low_1pct_fps: fps(m.low_1pct_fps), - p95_ms: m.p95_ms == null ? null : { value: m.p95_ms / scale, low: m.p95_ms / hi, high: m.p95_ms / lo }, - world_ms: time(m.world_ms, c), - chunk_load_ms: time(m.chunk_load_ms, c), - rss_steady_mb: m.rss_steady_mb == null ? null : { value: m.rss_steady_mb }, - rss_peak_mb: m.rss_peak_mb == null ? null : { value: m.rss_peak_mb }, - notes: memoryNotes(base.row, gpu, ramGb), - }; -} diff --git a/website/src/estimate.test.mjs b/website/src/estimate.test.mjs deleted file mode 100644 index d46f9c4b5..000000000 --- a/website/src/estimate.test.mjs +++ /dev/null @@ -1,101 +0,0 @@ -// node --test website/src/ (npm test in website/) -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { estimate, factors, measured, nearest } from "./estimate.js"; -import { CPUS, GPUS, findCpu, findGpu } from "./hardware.js"; - -const row = (gpu, cpu, medians, extra = {}) => ({ gpu, cpu, render_distance: 12, game: "horizon", source: "us", medians, ...extra }); -const mac = row("Apple M4 Pro", "Apple M4 Pro", { avg_fps: 500, low_1pct_fps: 100, p95_ms: 3, world_ms: 4000, chunk_load_ms: 1000, rss_peak_mb: 1500, rss_steady_mb: 1200 }); -const laptop = row("Mesa Intel HD Graphics 620 (KBL GT2)", "Intel Core i7-7500U CPU @ 2.70GHz", { avg_fps: 100, low_1pct_fps: 20, p95_ms: 15, world_ms: 12000, chunk_load_ms: 5000, rss_peak_mb: 1400, rss_steady_mb: 1100, cpu_p50_ms: 4, gpu_p50_ms: 9 }); - -test("every reported name of our machines is in the table", () => { - assert.equal(findGpu("Apple M4 Pro").name, "Apple M4 Pro (20-core GPU)"); - assert.equal(findGpu("Mesa Intel HD Graphics 620 (KBL GT2)").name, "Intel HD Graphics 620"); - assert.equal(findGpu("NVIDIA GeForce RTX 4080").name, "NVIDIA GeForce RTX 4080"); - assert.equal(findGpu("NVIDIA GeForce RTX 4080 SUPER"), null); - assert.equal(findCpu("13th Gen Intel Core i7-13700KF").name, "Intel Core i7-13700K / KF"); - assert.equal(findCpu("Intel Core i7-7500U CPU @ 2.70GHz").name, "Intel Core i7-7500U"); - for (const g of GPUS) assert.equal(findGpu(g.name), g, g.name); - for (const c of CPUS) assert.equal(findCpu(c.name), c, c.name); -}); - -test("an exact match is a measurement, not an estimate", () => { - assert.deepEqual(measured([mac, laptop], findGpu("Apple M4 Pro"), findCpu("Apple M4 Pro"), 12, "horizon"), [mac]); - assert.deepEqual(measured([mac], findGpu("Apple M4 Pro"), findCpu("Apple M4 Pro"), 32, "horizon"), []); -}); - -test("the nearest machine is the base, and the range holds the estimate", () => { - const gpu = findGpu("Intel Iris Xe Graphics (96 EU)"); - const cpu = findCpu("Intel Core i5-1135G7"); - assert.equal(nearest([mac, laptop], gpu, cpu, 12, "horizon").row, laptop); - const e = estimate([mac, laptop], gpu, cpu, 12, "horizon"); - const f = factors(findGpu(laptop.gpu), findCpu(laptop.cpu), gpu, cpu); - assert.ok(f.gpu > 1 && f.cpu > 1); - for (const key of ["avg_fps", "low_1pct_fps", "p95_ms", "world_ms", "chunk_load_ms"]) { - const { value, low, high } = e[key]; - assert.ok(low <= value && value <= high, key); - } - // GPU-bound frames (9 ms GPU, 4 ms CPU) follow the GPU factor. - assert.ok(Math.abs(e.avg_fps.value - 100 * Math.min(f.gpu, 9 / (4 / f.cpu))) < 1e-9); - assert.ok(Math.abs(e.world_ms.value - 12000 / f.cpu) < 1e-9); - assert.equal(e.rss_steady_mb.value, 1100); -}); - -test("no base, no estimate", () => { - assert.equal(estimate([mac], findGpu("Apple M4"), findCpu("Apple M4"), 32, "horizon"), null); - assert.equal(estimate([mac], findGpu("Apple M4"), findCpu("Apple M4"), 12, "vanilla"), null); -}); - -test("a base held to its display's refresh rate gives only a floor for the average", () => { - const held = { ...mac, capped_fps: 144, medians: { ...mac.medians, avg_fps: 144 } }; - const e = estimate([held], findGpu("NVIDIA GeForce RTX 4060"), findCpu("AMD Ryzen 5 5600X"), 12, "horizon"); - assert.equal(e.avg_fps.atLeast, true); - assert.equal(e.avg_fps.high, null); - assert.ok(e.avg_fps.low > 0); - // The lows sit under the cap: still a range. - assert.ok(e.low_1pct_fps.high > e.low_1pct_fps.low); -}); - -test("a release title of issue numbers falls back to its notes", async () => { - const { parts } = await import("./releases.js"); - const r = { tag_name: "v2026.10.22.07", name: "Horizon 2026.10.22.07: #1255, mob pushes, fix, #1222", body: "### Fixed\n- Fix #1222: single player runs in the frame again (#1257, fixes #1222)\n- Mob pushes: one list per mob (#1205)\n" }; - assert.equal(parts(r).line, "Single player runs in the frame again and 1 more"); - assert.equal(parts({ ...r, name: "Horizon 2026.10.22.05: Hoppers, mob tick, server tick" }).line, "Hoppers, mob tick, server tick"); -}); - -test("players' memory classes steer the base and the notes", async () => { - const { memoryNotes } = await import("./estimate.js"); - const { ramClass, vramClass } = await import("./hardware.js"); - assert.equal(vramClass(findGpu("NVIDIA GeForce RTX 4080")), "16 GB"); - assert.equal(vramClass(findGpu("NVIDIA GeForce RTX 3080")), "12 GB"); - assert.equal(vramClass(findGpu("Apple M4 Pro")), "shared"); - assert.equal(ramClass(16), "16 GB"); - // Two equally near groups: the one whose players have the picked GPU's memory class wins. - const players = (vram, ram, fps) => - row("NVIDIA GeForce RTX 4070", "AMD Ryzen 5 7600X", { avg_fps: fps, rss_peak_mb: 3600 }, { source: "players", installs: 3, breakdowns: { vram: [{ class: vram, installs: 3 }], ram: [{ class: ram, installs: 3 }] } }); - const a = players("8 GB", "16 GB", 400); - const b = { ...players("12 GB", "32 GB", 500), cpu: "AMD Ryzen 7 7800X3D" }; - const gpu = findGpu("NVIDIA GeForce RTX 4070 Ti"); - const cpu = findCpu("AMD Ryzen 5 7600X"); - assert.equal(nearest([a, b], gpu, cpu, 12, "horizon").row, b, "12 GB like the 4070 Ti, though the CPU differs"); - assert.equal(nearest([a, b], gpu, cpu, 12, "horizon", { ramGb: 16 }).row, a, "both classes count"); - assert.deepEqual(memoryNotes(b, gpu, 32), []); - const notes = memoryNotes(a, gpu, 8); - assert.equal(notes.length, 3); - assert.match(notes[0], /8 GB \(3\) of their own memory; this one has 12 GB/); - assert.match(notes[1], /16 GB \(3\) of memory, not 8 GB/); - assert.match(notes[2], /Horizon used up to 3\.5 GB/); - // Our own rows have no breakdowns: only the peak memory note. - assert.deepEqual(memoryNotes(mac, gpu, 32), []); - assert.equal(estimate([a], gpu, cpu, 12, "horizon", { ramGb: 8 }).notes.length, 3); -}); - -test("breakdowns read as words, and the hardware table shows only with data", async () => { - const { breakdownLine, hardwareHtml } = await import("./bench.js"); - assert.equal(breakdownLine({ ram: [{ class: "32 GB", installs: 3 }], storage: [{ class: "nvme", installs: 4 }], gpu_kind: [{ class: "discrete", installs: 3 }] }), "32 GB RAM (3) · NVMe (4) · separate GPU (3)"); - assert.equal(breakdownLine(undefined), ""); - assert.equal(hardwareHtml({ breakdowns: {} }), ""); - const html = hardwareHtml({ breakdowns: { os: [{ class: "linux", installs: 5, sessions: 9, hours: 4.5, p50_ms: 6.2, p99_ms: 14 }] } }); - assert.match(html, /Linux/); - assert.match(html, /6\.2 ms/); -}); diff --git a/website/src/hardware.js b/website/src/hardware.js deleted file mode 100644 index bf4c8bbbd..000000000 --- a/website/src/hardware.js +++ /dev/null @@ -1,88 +0,0 @@ -// The hardware the Benchmarks page can estimate for, from specifications only (our own table, no benchmark -// database): each GPU's FP32 shader throughput in TFLOPS and its memory bandwidth in GB/s as its maker states them, -// its own memory in GB (vram_gb; 0 for a GPU that shares the system's: integrated, Apple silicon), -// and each CPU's single-thread score = its top boost clock (GHz) x our rough per-generation factor (instructions per -// clock, Zen 4 = 1). The factors are round numbers, so estimates are ranges, and the page says so. -// `match` finds a reported name (the GPU and CPU strings in benchmark reports) in this table. - -export const GPUS = [ - { name: "NVIDIA GeForce RTX 4090", tflops: 82.6, gbps: 1008, vram_gb: 24, match: /RTX 4090/i }, - { name: "NVIDIA GeForce RTX 4080", tflops: 48.7, gbps: 717, vram_gb: 16, match: /RTX 4080(?! SUPER)/i }, - { name: "NVIDIA GeForce RTX 4070 Ti", tflops: 40.1, gbps: 504, vram_gb: 12, match: /RTX 4070 Ti/i }, - { name: "NVIDIA GeForce RTX 4070", tflops: 29.1, gbps: 504, vram_gb: 12, match: /RTX 4070(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 4060 Ti", tflops: 22.1, gbps: 288, vram_gb: 8, match: /RTX 4060 Ti/i }, - { name: "NVIDIA GeForce RTX 4060", tflops: 15.1, gbps: 272, vram_gb: 8, match: /RTX 4060(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 3080", tflops: 29.8, gbps: 760, vram_gb: 10, match: /RTX 3080(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 3070", tflops: 20.3, gbps: 448, vram_gb: 8, match: /RTX 3070(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 3060", tflops: 12.7, gbps: 360, vram_gb: 12, match: /RTX 3060(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 3050", tflops: 9.1, gbps: 224, vram_gb: 8, match: /RTX 3050/i }, - { name: "NVIDIA GeForce RTX 2060", tflops: 6.5, gbps: 336, vram_gb: 6, match: /RTX 2060(?! SUPER)/i }, - { name: "NVIDIA GeForce GTX 1660 SUPER", tflops: 5.0, gbps: 336, vram_gb: 6, match: /GTX 1660 SUPER/i }, - { name: "NVIDIA GeForce GTX 1060 6GB", tflops: 4.4, gbps: 192, vram_gb: 6, match: /GTX 1060/i }, - { name: "NVIDIA GeForce GTX 1050 Ti", tflops: 2.1, gbps: 112, vram_gb: 4, match: /GTX 1050 Ti/i }, - { name: "AMD Radeon RX 7900 XTX", tflops: 61.4, gbps: 960, vram_gb: 24, match: /RX 7900 XTX/i }, - { name: "AMD Radeon RX 7800 XT", tflops: 37.3, gbps: 624, vram_gb: 16, match: /RX 7800 XT/i }, - { name: "AMD Radeon RX 7600", tflops: 21.8, gbps: 288, vram_gb: 8, match: /RX 7600(?! XT)/i }, - { name: "AMD Radeon RX 6700 XT", tflops: 13.2, gbps: 384, vram_gb: 12, match: /RX 6700 XT/i }, - { name: "AMD Radeon RX 6600", tflops: 8.9, gbps: 224, vram_gb: 8, match: /RX 6600(?! XT)/i }, - { name: "AMD Radeon RX 580", tflops: 6.2, gbps: 256, vram_gb: 8, match: /RX 580/i }, - { name: "AMD Radeon 780M (integrated)", tflops: 8.6, gbps: 89, vram_gb: 0, match: /780M/i }, - { name: "Steam Deck (AMD Van Gogh)", tflops: 1.6, gbps: 88, vram_gb: 0, match: /VanGogh|Van Gogh|Custom GPU 0405/i }, - { name: "Intel Arc A770", tflops: 19.7, gbps: 560, vram_gb: 16, match: /Arc.*A770/i }, - { name: "Intel Iris Xe Graphics (96 EU)", tflops: 2.1, gbps: 68, vram_gb: 0, match: /Iris\(?R?\)? Xe/i }, - { name: "Intel HD Graphics 620", tflops: 0.4, gbps: 34, vram_gb: 0, match: /\bU?HD Graphics 620\b/i }, - { name: "Apple M1", tflops: 2.6, gbps: 68, vram_gb: 0, match: /^Apple M1$/i }, - { name: "Apple M2", tflops: 3.6, gbps: 100, vram_gb: 0, match: /^Apple M2$/i }, - { name: "Apple M3", tflops: 4.1, gbps: 100, vram_gb: 0, match: /^Apple M3$/i }, - { name: "Apple M4", tflops: 4.3, gbps: 120, vram_gb: 0, match: /^Apple M4$/i }, - { name: "Apple M1 Pro (16-core GPU)", tflops: 5.2, gbps: 200, vram_gb: 0, match: /^Apple M1 Pro$/i }, - { name: "Apple M4 Pro (20-core GPU)", tflops: 8.5, gbps: 273, vram_gb: 0, match: /^Apple M4 Pro$/i }, - { name: "Apple M4 Max (40-core GPU)", tflops: 17.0, gbps: 546, vram_gb: 0, match: /^Apple M4 Max$/i }, -]; - -// ghz: top single-core boost clock; ipc: our per-generation factor (Zen 4 = 1). -export const CPUS = [ - { name: "Intel Core i7-7500U", ghz: 3.5, ipc: 0.62, match: /i7-7500U/i }, - { name: "Intel Core i5-7200U", ghz: 3.1, ipc: 0.62, match: /i5-7200U/i }, - { name: "Intel Core i5-8250U", ghz: 3.4, ipc: 0.62, match: /i5-8250U/i }, - { name: "Intel Core i5-9400F", ghz: 4.1, ipc: 0.62, match: /i5-9400/i }, - { name: "Intel Core i7-9700K", ghz: 4.9, ipc: 0.62, match: /i7-9700K/i }, - { name: "Intel Core i5-10400", ghz: 4.3, ipc: 0.62, match: /i5-10400/i }, - { name: "Intel Core i5-1135G7", ghz: 4.2, ipc: 0.78, match: /i5-1135G7/i }, - { name: "Intel Core i5-12400", ghz: 4.4, ipc: 0.95, match: /i5-12400/i }, - { name: "Intel Core i5-13600K", ghz: 5.1, ipc: 0.95, match: /i5-13600K/i }, - { name: "Intel Core i7-13700K / KF", ghz: 5.4, ipc: 0.95, match: /i7-13700K/i }, - { name: "Intel Core i9-14900K", ghz: 6.0, ipc: 0.95, match: /i9-14900K/i }, - { name: "AMD Ryzen 5 3600", ghz: 4.2, ipc: 0.72, match: /Ryzen 5 3600/i }, - { name: "AMD Ryzen 5 5600X", ghz: 4.6, ipc: 0.88, match: /Ryzen 5 5600X?\b/i }, - { name: "AMD Ryzen 7 5800X3D", ghz: 4.5, ipc: 0.88, match: /5800X3D/i }, - { name: "AMD Ryzen 5 7600X", ghz: 5.3, ipc: 1.0, match: /Ryzen 5 7600/i }, - { name: "AMD Ryzen 7 7800X3D", ghz: 5.0, ipc: 1.0, match: /7800X3D/i }, - { name: "AMD Ryzen 9 7950X", ghz: 5.7, ipc: 1.0, match: /Ryzen 9 7950X(?!3D)/i }, - { name: "AMD Ryzen 7 9700X", ghz: 5.5, ipc: 1.12, match: /Ryzen 7 9700X/i }, - { name: "AMD Ryzen 7 7840U / HS", ghz: 5.1, ipc: 1.0, match: /7840(U|HS)/i }, - { name: "Steam Deck (AMD Zen 2, 4 cores)", ghz: 3.5, ipc: 0.72, match: /AMD Custom APU 0405|Van ?Gogh/i }, - { name: "Apple M1", ghz: 3.2, ipc: 1.25, match: /^Apple M1$/i }, - { name: "Apple M2", ghz: 3.5, ipc: 1.28, match: /^Apple M2$/i }, - { name: "Apple M3", ghz: 4.05, ipc: 1.38, match: /^Apple M3$/i }, - { name: "Apple M4", ghz: 4.4, ipc: 1.5, match: /^Apple M4$/i }, - { name: "Apple M4 Pro", ghz: 4.5, ipc: 1.5, match: /^Apple M4 Pro$/i }, -]; - -export const singleThread = (cpu) => cpu.ghz * cpu.ipc; -export const findGpu = (name) => GPUS.find((g) => g.name === name || g.match.test(name || "")) || null; -export const findCpu = (name) => CPUS.find((c) => c.name === name || c.match.test(name || "")) || null; - -// The telemetry server's hardware classes (crates/horizon-telemetry-server/src/classes.rs), which players' -// results are broken down by: memory and a GPU's own memory in coarse steps. -export function ramClass(gb) { - if (!(gb > 0)) return null; - return gb <= 5 ? "4 GB or less" : gb <= 10 ? "8 GB" : gb <= 20 ? "16 GB" : gb <= 40 ? "32 GB" : "64 GB or more"; -} -export function vramClass(gpu) { - if (!gpu) return null; - if (!gpu.vram_gb) return "shared"; - const mb = gpu.vram_gb * 1024; - for (const [max, name] of [[2560, "2 GB or less"], [4608, "4 GB"], [6656, "6 GB"], [8704, "8 GB"], [12800, "12 GB"], [16896, "16 GB"]]) if (mb <= max) return name; - return "more than 16 GB"; -} diff --git a/website/src/hub-page.js b/website/src/hub-page.js deleted file mode 100644 index 9115c5ca4..000000000 --- a/website/src/hub-page.js +++ /dev/null @@ -1,270 +0,0 @@ -// The hub page (/hub): its tabs (one per #hash, so /hub#roadmap and /hub# can be linked), the -// Simple / Technical switch (remembered in localStorage), the area filter, and the live state of every -// linked issue and pull request, refreshed from GitHub's public API. Without JavaScript, every tab reads -// one after the other with what was baked at build time. -import { cached, get, setUpMenu, setUpTheme } from "./chrome.js"; -import { REPO, boardFilterHtml, boardFrom, boardHtml, journalFrom, journalHtml, overviewHtml, roadmapHtml, slimIssue } from "./hub.js"; - -const root = document.documentElement; -const data = JSON.parse(document.getElementById("hub-data").textContent); -const tabs = [...document.querySelectorAll('.hub-tabs [role="tab"]')]; -const pill = document.querySelector(".hub-tabs .tab-pill"); -let area = "all"; - -// --- Tabs ------------------------------------------------------------------------------------------------------ - -const panelOf = (tab) => document.getElementById(tab.getAttribute("aria-controls")); -const place = (tab) => { - pill.style.width = `${tab.offsetWidth}px`; - pill.style.height = `${tab.offsetHeight}px`; - pill.style.transform = `translate(${tab.offsetLeft}px, ${tab.offsetTop}px)`; -}; -function choose(tab, { focus = false, push = false } = {}) { - for (const t of tabs) { - const on = t === tab; - t.setAttribute("aria-selected", String(on)); - t.tabIndex = on ? 0 : -1; - panelOf(t).classList.toggle("is-active", on); - } - place(tab); - if (focus) tab.focus(); - if (push && location.hash !== `#${panelOf(tab).id}`) history.replaceState(null, "", `#${panelOf(tab).id}`); -} -// "#roadmap" opens its tab; "#" or "#help-wanted" opens the tab holding it and scrolls there. -function follow() { - const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1))); - const tab = tabs.find((t) => target && (panelOf(t) === target || panelOf(t).contains(target))) || tabs[0]; - choose(tab); - if (target && target !== panelOf(tab)) { - if (target.dataset.area && area !== "all" && target.dataset.area !== area) filter("all"); - requestAnimationFrame(() => target.scrollIntoView({ block: "start" })); - } -} -tabs.forEach((tab, i) => { - tab.addEventListener("click", () => choose(tab, { push: true })); - tab.addEventListener("keydown", (e) => { - const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; - if (step) choose(tabs[(i + step + tabs.length) % tabs.length], { focus: true, push: true }); - }); -}); -// Links inside the page ("Help wanted", an item of the overview) switch tabs through the hash. -addEventListener("hashchange", follow); -addEventListener("resize", () => place(tabs.find((t) => t.getAttribute("aria-selected") === "true"))); -document.fonts?.ready.then(() => place(tabs.find((t) => t.getAttribute("aria-selected") === "true"))); - -// --- Simple / Technical ---------------------------------------------------------------------------------------- - -const detailButtons = [...document.querySelectorAll("[data-detail]")]; -function detail(mode) { - if (mode === "dev") root.dataset.detail = "dev"; - else delete root.dataset.detail; - detailButtons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.detail === mode))); -} -detailButtons.forEach((b) => - b.addEventListener("click", () => { - detail(b.dataset.detail); - try { - localStorage.setItem("hub:detail", b.dataset.detail); - } catch {} - }), -); -detail(root.dataset.detail === "dev" ? "dev" : "plain"); - -// --- Area filter ----------------------------------------------------------------------------------------------- - -const areaButtons = [...document.querySelectorAll("[data-area-filter]")]; -function filter(to) { - area = to; - areaButtons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.areaFilter === area))); - for (const section of document.querySelectorAll("[data-roadmap] .hub-section")) { - let shown = 0; - for (const item of section.querySelectorAll(".hub-item")) { - item.hidden = area !== "all" && item.dataset.area !== area; - if (!item.hidden) shown++; - } - section.hidden = shown === 0; - } -} -areaButtons.forEach((b) => b.addEventListener("click", () => filter(b.dataset.areaFilter))); - -// --- The board: a roadmap item's cards, and the long columns' "Show more" ---------------------------------------- - -const boardSelect = document.querySelector("[data-board-item]"); -function showItem() { - const want = boardSelect.value; - for (const card of document.querySelectorAll("[data-board] .hub-card")) { - card.hidden = want === "-" ? !card.dataset.item : want ? card.dataset.item !== want : false; - } - for (const column of document.querySelectorAll("[data-board] .hub-column")) { - column.querySelector("[data-count]").textContent = column.querySelectorAll(".hub-card:not([hidden])").length; - // A filtered column shows all its cards. - column.classList.toggle("is-open", Boolean(want) || column.classList.contains("was-opened")); - } -} -boardSelect?.addEventListener("change", showItem); -document.querySelector("[data-board]")?.addEventListener("click", (e) => { - const button = e.target.closest("[data-more-button]"); - if (!button) return; - button.closest(".hub-column").classList.add("is-open", "was-opened"); - button.remove(); -}); - -// --- Live state from GitHub ------------------------------------------------------------------------------------ - -// The open issues and pull requests, and those closed or merged lately: four requests at most, kept ten minutes. -async function loadLive() { - const list = []; - for (let page = 1; page <= 3; page++) { - const batch = await get(`https://api.github.com/repos/${REPO}/issues?state=open&per_page=100&page=${page}`); - list.push(...batch.map(slimIssue)); - if (batch.length < 100) break; - } - const closed = await get(`https://api.github.com/repos/${REPO}/issues?state=closed&sort=updated&direction=desc&per_page=100`); - return list.concat(closed.map(slimIssue)); -} -const loadReleases = () => get(`https://api.github.com/repos/${REPO}/releases?per_page=40`); - -async function refresh() { - const fresh = await cached("horizon:hub-live", loadLive).catch(() => null); - if (!fresh) return; - // What the build knew about older links stays; GitHub's answer wins for the rest. - const live = new Map([...data.live, ...fresh].map((i) => [i.n, i])); - document.querySelector("[data-overview]").innerHTML = overviewHtml(data.roadmap, live); - document.querySelector("[data-roadmap]").innerHTML = roadmapHtml(data.roadmap, live); - filter(area); - showVotes(); - const board = boardFrom(fresh, data.roadmap); - const chosen = boardSelect.value; - boardSelect.innerHTML = boardFilterHtml(board, data.roadmap); - boardSelect.value = [...boardSelect.options].some((o) => o.value === chosen) ? chosen : ""; - document.querySelector("[data-board]").innerHTML = boardHtml(board, data.roadmap); - showItem(); - const releases = await cached("horizon:hub-releases", loadReleases).catch(() => null); - if (releases) document.querySelector("[data-journal]").innerHTML = journalHtml(journalFrom(releases, fresh, data.roadmap), data.roadmap); - const note = document.querySelector("[data-live-note]"); - if (note) note.textContent = "Pull request and issue states are live from GitHub."; -} - -// --- Votes, bug reports and suggestions: the hub's server (crates/horizon-telemetry-server) -------------------- - -const saved = (key, fallback) => { - try { - return JSON.parse(localStorage.getItem(key)) ?? fallback; - } catch { - return fallback; - } -}; -const save = (key, value) => { - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch {} -}; -const post = (path, body) => fetch(`${data.api}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); - -// This browser's random id (one vote per item per browser) and the items it voted for. Nothing else identifies it. -function voter() { - let id = saved("hub:voter", ""); - if (typeof id !== "string" || id.length < 16) { - id = crypto.randomUUID ? crypto.randomUUID() : [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, "0")).join(""); - save("hub:voter", id); - } - return id; -} -const mine = new Set(saved("hub:voted", [])); -let counts = null; // { item: votes } once the server answered; until then the buttons stay hidden - -function showVotes() { - if (!counts) return; - for (const button of document.querySelectorAll("[data-vote]")) { - const id = button.dataset.vote; - button.hidden = false; - button.setAttribute("aria-pressed", String(mine.has(id))); - button.querySelector(".hub-vote-label").textContent = mine.has(id) ? "Wanted" : "Want it"; - button.querySelector("[data-votes]").textContent = counts[id] ? String(counts[id]) : ""; - } -} - -async function vote(id) { - const up = !mine.has(id); - const apply = (on, step) => { - if (on) mine.add(id); - else mine.delete(id); - counts[id] = Math.max(0, (counts[id] || 0) + step); - save("hub:voted", [...mine]); - showVotes(); - }; - apply(up, up ? 1 : -1); - try { - const res = await post("/hub/vote", { item: id, voter: voter(), up }); - if (!res.ok) throw new Error(res.status); - counts[id] = (await res.json()).votes; - showVotes(); - } catch { - apply(!up, up ? -1 : 1); - } -} -document.addEventListener("click", (e) => { - const button = e.target.closest("[data-vote]"); - if (button && counts) vote(button.dataset.vote); -}); - -const LABELS = { title: "the title", did: "what you did", happened: "what happened", expected: "what you expected", version: "the version", system: "OS, CPU, GPU", text: "your idea", name: "your name" }; -// The server's answer in words: "did: 3 to 2000 characters" becomes "Check what you did: 3 to 2000 characters." -function trouble(code, answer) { - if (code === 429) return "Too many sent from your connection: try again in a few minutes."; - const said = typeof answer.error === "string" ? answer.error : ""; - const field = said.match(/^(\w+): (.+)$/); - if (code === 400 && field) return `Check ${LABELS[field[1]] || field[1]}: ${field[2]}.`; - return said ? `${said[0].toUpperCase()}${said.slice(1)}.` : "That didn't go through: try again in a moment."; -} - -function setUpForm(form, path, thanks) { - const status = form.querySelector("[data-status]"); - form.hidden = false; - form.parentElement.querySelector("[data-closed]").hidden = true; - form.addEventListener("submit", async (e) => { - e.preventDefault(); - if (!form.reportValidity()) return; - const button = form.querySelector('[type="submit"]'); - button.disabled = true; - status.textContent = "Sending…"; - try { - const res = await post(path, Object.fromEntries(new FormData(form))); - const answer = await res.json().catch(() => ({})); - if (res.ok) { - thanks(status, answer); - // A new report starts with the version filled in again; the name stays for the next one. - for (const field of form.querySelectorAll("textarea, input:not([name='version']):not([name='name'])")) field.value = ""; - } else status.textContent = trouble(res.status, answer); - } catch { - status.textContent = "Couldn't reach the server: try again, or use GitHub or Discord."; - } - button.disabled = false; - }); -} - -// The forms and the votes show once the server answers; without it the page points to GitHub and Discord. -async function connect() { - try { - // Revalidated: the server keeps the counts a minute, a browser's copy would miss this browser's own vote. - const res = await fetch(`${data.api}/hub/votes`, { cache: "no-cache" }); - if (!res.ok) return; - counts = (await res.json()).votes || {}; - } catch { - return; - } - showVotes(); - setUpForm(document.querySelector('[data-form="report"]'), "/hub/report", (status, answer) => { - const issue = typeof answer.issue === "string" && answer.issue.match(/^https:\/\/github\.com\/ludovic111\/horizon\/issues\/(\d+)$/); - status.innerHTML = issue ? `Thanks! It's issue #${issue[1]} on GitHub: follow it there.` : "Thanks! It will be on GitHub in a few minutes."; - }); - setUpForm(document.querySelector('[data-form="suggest"]'), "/hub/suggest", (status) => { - status.textContent = "Thanks! The maintainers read every suggestion."; - }); -} - -setUpTheme(); -setUpMenu(); -connect(); -follow(); -refresh().then(() => data.roadmap.items.some((i) => `#${i.id}` === location.hash) && follow()); diff --git a/website/src/hub.html b/website/src/hub.html deleted file mode 100644 index 167ffc15e..000000000 --- a/website/src/hub.html +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - Hub · Horizon - - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} -
    -
    -

    HubNow, next and how to help

    -

    Where Horizon is going

    -

    What is being done, what comes next, what shipped and where you can help. No dates: things are Now, Next or Later. Read it in plain words, or switch to the technical picture.

    -
    -
    - - - - - - -
    -
    - - -
    -
    - -
    -{{HUB_OVERVIEW}} -
    - -
    -
    -{{HUB_AREAS}} -
    -
    -{{HUB_ROADMAP}} -
    -

    The roadmap lives in the repository: ROADMAP.md, generated from roadmap/roadmap.toml. Pull requests carry the roadmap:<id> label of their item.

    -
    - -
    -
    -

    Every open issue and pull request, placed by its state on GitHub, as on Kira's work board in Discord.

    - -
    -
    -{{HUB_BOARD}} -
    -
    - -
    -

    Every release with what it changed, grouped by roadmap item in Technical, and a summary of each week. Also as journal.json.

    -
    -{{HUB_JOURNAL}} -
    -
    - -
    -
    -

    Good first tasks

    -

    Each one is a small difference from vanilla, with an issue that says how to see it.

    -

    Each links its issue; the technical text says where in the code and in vanilla to start.

    -
      {{HUB_HELP_WANTED}}
    -
    -
    -

    Ways to help

    -
    -
    -

    Tell us what differs

    -

    Something that doesn't play like vanilla is a bug. Say what you did, what happened and what vanilla does, with your Horizon version and system.

    -

    Report a bugSuggest something

    -
    -
    -

    Take a task

    -

    Pick a good first task or an open issue. On Discord, /claim 1234 tells everyone you are on issue 1234, so nobody starts the same work; /unclaim frees it.

    -

    Open issues

    -
    -
    -

    Bring your AI agent

    -

    On Discord, /agent connect gives your coding agent a personal token for Kira's MCP server: it reads the work board, claims tasks and posts its progress in your name.

    -

    Join the Discord

    -
    -
    -

    Benchmark your computer

    -

    Run the built-in benchmark and share the report: every new machine makes the performance work more accurate. No coding needed.

    -

    How to

    -
    -
    -
    -
    -

    Report a bug

    -

    One bug per report: something that breaks, or doesn't play like vanilla. It becomes an issue on GitHub, public, and you get its link to follow it. Search the open bugs first.

    - -

    Report it on GitHub or in #horizon-bugs on Discord, where Kira turns your post into an issue.

    -
    -
    -

    Suggest something

    -

    An idea for Horizon, or about something on the roadmap. The maintainers read every one, and the good ones become roadmap items. Nothing you send here is published.

    - -

    Post it in #horizon-ideas on Discord, or send it to Kira with /suggest.

    -
    -
    -

    The rules

    -
      -
    1. Vanilla is the specification. Wherever Minecraft 26.3 has the feature, Horizon does exactly what it does: read vanilla's code, then match it.
    2. -
    3. Small pull requests, proven. One change each, under about 500 lines, with the block python3 tools/pr_ready.py prints: formatting, Clippy, tests and the harnesses your change can affect.
    4. -
    5. Say who wrote it. If an AI agent wrote or posts it, the pull request says so, and commits keep its co-author line.
    6. -
    7. Never commit Mojang's files. No game jars, assets, decompiled code or prepared resources, and nothing taken from other mods' code.
    8. -
    -

    The contributing guideCONTRIBUTING.md

    -
    -
    -

    Contributors

    -

    Everyone with commits in Horizon. On Discord, /link-github <your login> credit:True adds your Discord name next to yours.

    -
      {{HUB_CREDIT}}
    -

    All contributors on GitHub

    -
    -
    -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    -{{SITE_FOOTER}} - - - diff --git a/website/src/hub.js b/website/src/hub.js deleted file mode 100644 index f11668d8c..000000000 --- a/website/src/hub.js +++ /dev/null @@ -1,457 +0,0 @@ -// The hub (/hub): the roadmap from website/data/roadmap.json (generated from roadmap/roadmap.toml by -// tools/roadmap.py), with each item's issues and pull requests in their live GitHub state. Shared by build.mjs -// (baked into the page) and hub-page.js (refreshed from GitHub in the browser). Every item says the same thing -// twice: `plain` (.hub-plain) for players and `dev` (.hub-dev) for contributors; the page's Simple / Technical -// switch shows one of them. -import { escape, inline } from "./markdown.js"; -import { parts, plainItem, sections } from "./releases.js"; -import { isGame, isLauncher } from "./versions.js"; -import { avatar } from "./contributors.js"; - -export const REPO = "ludovic111/horizon"; -const issueUrl = (n) => `https://github.com/${REPO}/issues/${n}`; -const fileUrl = (path) => `https://github.com/${REPO}/blob/main/${path}`; - -// The issues a pull request closes when merged: "Fixes #12", "closes #3, resolves #4" (as Kira's board reads them). -export function fixesIn(text) { - const out = new Set(); - for (const m of String(text ?? "").matchAll(/\b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\s*:?\s+((?:#\d+(?:\s*(?:,|and)\s*)?)+)/gi)) { - for (const n of m[1].matchAll(/#(\d+)/g)) out.add(Number(n[1])); - } - return [...out].sort((a, b) => a - b); -} - -// A GitHub issue or pull request (from /issues, which lists both) reduced to what the hub shows. -export function slimIssue(raw) { - const pr = Boolean(raw.pull_request); - const merged = pr && raw.pull_request.merged_at; - return { - n: raw.number, - title: String(raw.title || ""), - kind: pr ? "pr" : "issue", - state: merged ? "merged" : raw.state === "open" ? "open" : raw.state_reason === "not_planned" ? "dropped" : "closed", - draft: Boolean(raw.draft), - user: raw.user?.login || "", - labels: (raw.labels || []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), - assigned: (raw.assignees || []).length > 0, - fixes: pr ? fixesIn(raw.body) : [], - at: merged || raw.closed_at || raw.updated_at || "", - url: raw.html_url || issueUrl(raw.number), - }; -} - -const number = (link) => Number((String(link).match(/^#(\d+)$/) || [])[1]) || null; - -// "#12 in review", "#13 merged": a link's state, when GitHub told us. -function linkState(issue) { - if (!issue) return null; - if (issue.kind === "pr") return issue.state === "open" ? (issue.draft ? "in progress" : "in review") : issue.state; - return issue.state === "open" ? "open" : "closed"; -} - -function linkHtml(link, live) { - const n = number(link); - if (!n) { - const url = link.startsWith("https://") ? link : fileUrl(link); - return `${escape(link.replace(/^https:\/\//, ""))}`; - } - const issue = live.get(n); - const state = linkState(issue); - const label = issue ? `${issue.kind === "pr" ? "Pull request" : "Issue"} #${n}: ${issue.title}` : `#${n}`; - return `#${n}${state ? ` ${state}` : ""}`; -} - -// The technical text with its #N and the item's own paths as links. -function devHtml(item) { - let text = item.dev.replace(/(^|[^\w[/`])#(\d+)\b/g, (_, before, n) => `${before}[#${n}](${issueUrl(n)})`); - for (const link of item.links || []) { - if (!number(link) && !link.startsWith("https://")) text = text.replace(link, `[${link}](${fileUrl(link)})`); - } - return inline(text); -} - -// "2 pull requests in review · 3 merged · 1 issue open", from the item's links. -export function progress(item, live) { - const known = (item.links || []).map(number).filter(Boolean).map((n) => live.get(n)).filter(Boolean); - const pulls = known.filter((i) => i.kind === "pr"); - const count = (list, word) => (list.length ? `${list.length} ${word}` : ""); - const parts = [ - count(pulls.filter((p) => p.state === "open" && !p.draft), pulls.filter((p) => p.state === "open" && !p.draft).length === 1 ? "pull request in review" : "pull requests in review"), - count(pulls.filter((p) => p.state === "open" && p.draft), "in progress"), - count(pulls.filter((p) => p.state === "merged"), "merged"), - count(known.filter((i) => i.kind === "issue" && i.state === "open"), known.filter((i) => i.kind === "issue" && i.state === "open").length === 1 ? "issue open" : "issues open"), - ].filter(Boolean); - return parts.join(" · "); -} - -const areaName = (roadmap, id) => roadmap.areas.find((a) => a.id === id)?.name || id; - -// An item's vote button: hidden until the page has the counts from the hub's server (hub-page.js). -export function voteHtml(item) { - return ``; -} - -export function itemHtml(item, roadmap, live) { - const done = item.status === "shipped"; - const state = done ? "" : progress(item, live); - return `
    -

    ${escape(areaName(roadmap, item.area))}${done ? `${escape(item.shipped_in)}` : ""}${item.ongoing ? `Ongoing` : ""}${done ? "" : voteHtml(item)}

    -

    ${escape(item.title)}

    -

    ${inline(item.plain)}

    -

    ${devHtml(item)}

    - ${state ? `

    ${escape(state)}

    ` : ""} - -
    `; -} - -const SECTION_LEDE = { - now: "Being worked on, with pull requests open or just merged.", - next: "Decided, and up after what is being done now.", - later: "Decided, further out.", - "help-wanted": "Good first contributions: a clear vanilla behaviour, an issue with the steps, a place to start.", - shipped: "Out in a release. The journal has every change.", -}; - -// The Roadmap tab: one section per status, the items in the file's order (areas by priority). -export function roadmapHtml(roadmap, live = new Map()) { - const order = new Map(roadmap.areas.map((a, i) => [a.id, i])); - return roadmap.statuses - .map(({ id, name }) => { - const items = roadmap.items.filter((i) => i.status === id); - if (!items.length) return ""; - // Shipped stays newest first; the others go by the areas' priority. - if (id !== "shipped") items.sort((a, b) => order.get(a.area) - order.get(b.area)); - return `
    -

    ${escape(name)}

    ${escape(SECTION_LEDE[id] || "")}

    -
    -${items.map((item) => itemHtml(item, roadmap, live)).join("\n")} -
    -
    `; - }) - .join("\n"); -} - -// The area filter's buttons: every area that has an item. -export function areaFilterHtml(roadmap) { - const used = roadmap.areas.filter((a) => roadmap.items.some((i) => i.area === a.id)); - return [``] - .concat(used.map((a) => ``)) - .join("\n"); -} - -// The Overview tab: where Horizon is, the 3 to 5 things in focus, what is next, what shipped last, how to help. -export function overviewHtml(roadmap, live = new Map()) { - const o = roadmap.overview; - const focus = roadmap.items.filter((i) => i.focus); - const next = roadmap.items.filter((i) => i.status === "next"); - const shipped = roadmap.items.filter((i) => i.status === "shipped").slice(0, 3); - const row = (item, i, meta) => `
  • ${String(i + 1).padStart(2, "0")}${escape(item.title)}${inline(item.plain)}${inline(item.dev)}${escape(meta(item))}
  • `; - return `
    -

    ${inline(o.today_plain)}

    -

    ${inline(o.today_dev)}

    -
    -
    -

    Being done now

    -
      ${focus.map((item, i) => row(item, i, (x) => progress(x, live))).join("")}
    -
    -
    -

    Next

    -
      ${next.map((item, i) => row(item, i, (x) => areaName(roadmap, x.area))).join("")}
    -
    -
    -

    Recently shipped

    -
      ${shipped.map((item, i) => row(item, i, (x) => `In ${x.shipped_in}`)).join("")}
    -
    -
    -

    How to help

    -

    ${inline(o.help_plain)}

    -

    ${inline(o.help_dev)}

    -

    ContributeHelp wantedReport a bugSuggest something

    -
    `; -} - -// The Contribute tab's list of good first tasks. -export function helpWantedHtml(roadmap) { - return roadmap.items - .filter((i) => i.status === "help-wanted") - .map((item, i) => { - const first = (item.links || []).map(number).find(Boolean); - const href = first ? issueUrl(first) : `#${item.id}`; - return `
  • ${String(i + 1).padStart(2, "0")}${escape(item.title)}${inline(item.plain)}${inline(item.dev)}${first ? `#${first}` : ""}
  • `; - }) - .join(""); -} - -// --- The work board -------------------------------------------------------------------------------------------- - -export const COLUMNS = [ - { id: "todo", name: "To do", about: "Open and free: pick one, and say so with /claim on Discord." }, - { id: "progress", name: "In progress", about: "Claimed, assigned, or a draft pull request." }, - { id: "review", name: "In review", about: "A pull request is ready for review." }, - { id: "done", name: "Done", about: "Closed or merged in the last seven days." }, -]; -const DONE_DAYS = 7; - -// The roadmap item an issue or pull request moves: its roadmap: label, else the item linking it, else (for a -// pull request) the item linking an issue it fixes. -export function itemOf(issue, roadmap) { - const label = issue.labels.find((l) => l.startsWith("roadmap:")); - const ids = new Set(roadmap.items.map((i) => i.id)); - if (label && ids.has(label.slice(8))) return label.slice(8); - const linking = (n) => roadmap.items.find((i) => (i.links || []).includes(`#${n}`))?.id; - return linking(issue.n) || issue.fixes.map(linking).find(Boolean) || null; -} - -// The board from GitHub's open issues and pull requests and those closed lately, placed as Kira's work board places -// them: an issue goes to review when an open, ready pull request fixes it, to progress when a draft one does or it is -// claimed or assigned; a pull request that fixes no open issue is a card of its own. -export function boardFrom(list, roadmap, now = Date.now()) { - const columns = Object.fromEntries(COLUMNS.map((c) => [c.id, []])); - const open = list.filter((i) => i.state === "open"); - const openIssues = new Set(open.filter((i) => i.kind === "issue").map((i) => i.n)); - const fixing = (n) => open.filter((p) => p.kind === "pr" && p.fixes.includes(n)); - const card = (i, prs = []) => ({ n: i.n, kind: i.kind, title: i.title, user: i.user, url: i.url, item: itemOf(i, roadmap) || prs.map((p) => itemOf(p, roadmap)).find(Boolean) || null, prs: prs.map((p) => p.n) }); - for (const i of open) { - if (i.kind === "issue") { - const prs = fixing(i.n); - const claimed = i.assigned || i.labels.some((l) => /^(in progress|claimed)$/i.test(l)); - const column = prs.some((p) => !p.draft) ? "review" : prs.length || claimed ? "progress" : "todo"; - columns[column].push(card(i, prs)); - } else if (!i.fixes.some((n) => openIssues.has(n))) { - columns[i.draft ? "progress" : "review"].push(card(i)); - } - } - const since = now - DONE_DAYS * 864e5; - const seen = new Set(); - for (const i of list) { - if (seen.has(i.n) || !["merged", "closed"].includes(i.state) || !(Date.parse(i.at) >= since)) continue; - seen.add(i.n); - columns.done.push(card(i)); - } - columns.done.sort((a, b) => b.n - a.n); - return columns; -} - -const SHOWN = 20; // cards per column before "Show more" - -function cardHtml(c, roadmap, more) { - const item = c.item && roadmap.items.find((i) => i.id === c.item); - return `
  • ${c.kind === "pr" ? "PR" : "Issue"} #${c.n}${c.prs.length ? ` · ${c.prs.map((n) => `PR #${n}`).join(", ")}` : ""}${escape(c.title)}${item ? `${escape(item.title)}` : ""}${c.user ? `${escape(c.user)}` : ""}
  • `; -} - -export function boardHtml(columns, roadmap) { - return COLUMNS.map(({ id, name, about }) => { - const cards = columns[id]; - return `
    -

    ${escape(name)}${cards.length}

    -

    ${escape(about)}

    -
      ${cards.map((c, i) => cardHtml(c, roadmap, i >= SHOWN)).join("")}
    - ${cards.length > SHOWN ? `` : ""} -
    `; - }).join("\n"); -} - -// The board's roadmap filter: every item that has a card. -export function boardFilterHtml(columns, roadmap) { - const used = new Set(Object.values(columns).flat().map((c) => c.item).filter(Boolean)); - return [``, ``] - .concat(roadmap.items.filter((i) => used.has(i.id)).map((i) => ``)) - .join(""); -} - -// --- The journal ----------------------------------------------------------------------------------------------- - -export const JOURNAL_URL = "https://playhorizon.dev/hub#journal"; -const ENTRIES = 40; - -// The journal, newest first: each release with its changes grouped by roadmap item, the weekly summaries -// (roadmap/journal/), and what merged since the newest game release. releases: GitHub's release objects; list: the -// hub's issues and pull requests (their roadmap: labels place changes the roadmap doesn't link). -export function journalFrom(releases, list, roadmap) { - const prs = new Map(list.filter((i) => i.kind === "pr").map((i) => [i.n, i])); - const placed = (numbers) => { - for (const n of numbers) { - const known = prs.get(n); - const id = known ? itemOf(known, roadmap) : roadmap.items.find((i) => (i.links || []).includes(`#${n}`))?.id; - if (id) return id; - } - return null; - }; - const ours = releases.filter((r) => !r.draft && (isGame(r.tag_name) || isLauncher(r.tag_name))); - const entries = ours.map((r) => { - const changes = sections(r.body) - .filter((x) => /^(new|what's new|fixed)/i.test(x.title)) - .flatMap((x) => x.items.map((text) => ({ section: /^fixed/i.test(x.title) ? "fixed" : "new", text }))) - .map((c) => { - const numbers = [...c.text.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); - return { ...c, text: plainItem(c.text), prs: numbers, item: placed(numbers) }; - }); - return { - id: r.tag_name, - kind: isLauncher(r.tag_name) ? "launcher" : "release", - date: r.published_at || "", - title: r.name || r.tag_name, - plain: parts(r).line, - url: r.html_url, - changes, - shipped: roadmap.items.filter((i) => i.status === "shipped" && i.shipped_in === r.tag_name).map((i) => i.id), - }; - }); - // Merged but in no release's notes yet: a release is cut from main a while after its last merge, so the newest - // releases' notes, not their dates, say what they hold. Pull requests players don't see (skip-release) never are. - const games = ours.filter((r) => isGame(r.tag_name)).map((r) => r.published_at).sort().reverse(); - const since = games[Math.min(4, games.length - 1)] || ""; - const noted = new Set(entries.flatMap((e) => e.changes.flatMap((c) => c.prs))); - const pending = list - .filter((i) => i.kind === "pr" && i.state === "merged" && i.at > since && !noted.has(i.n) && !i.labels.includes("skip-release")) - .sort((a, b) => b.at.localeCompare(a.at)); - if (pending.length) { - entries.push({ - id: "next-release", - kind: "merged", - date: pending[0].at, - title: "Merged, in the next release", - plain: `${pending.length} change${pending.length === 1 ? "" : "s"} merged since the last release.`, - url: `https://github.com/${REPO}/pulls?q=is%3Apr+is%3Amerged`, - changes: pending.map((p) => ({ section: "merged", text: p.title, prs: [p.n], item: itemOf(p, roadmap) })), - shipped: [], - }); - } - for (const w of roadmap.journal || []) { - // A week's summary sits after its last day's releases. - const end = new Date(Date.parse(w.date) + 7 * 864e5 - 1).toISOString(); - entries.push({ id: `week-${w.week}`, kind: "week", date: end, title: w.title, plain: w.plain, dev: w.dev, url: `${JOURNAL_URL.replace("#journal", "")}#week-${w.week}`, changes: [], shipped: [] }); - } - return entries.sort((a, b) => b.date.localeCompare(a.date)).slice(0, ENTRIES); -} - -const HUB = "https://playhorizon.dev/hub"; -const FEED = 300; - -// /data/journal.json, which Kira reads for #updates and X (kira's src/journal.js): one flat entry per merged pull -// request, release, shipped roadmap item and weekly summary, newest first. An entry's id never changes: Kira posts -// each id once. Shipped items carry `post` (at most 240 characters, tools/roadmap.py checks) and `announce`. -export function journalJson(releases, list, roadmap, generated = new Date().toISOString()) { - const items = new Map(roadmap.items.map((i) => [i.id, i])); - const ours = releases.filter((r) => !r.draft && (isGame(r.tag_name) || isLauncher(r.tag_name))); - const published = new Map(ours.map((r) => [r.tag_name, r.published_at || ""])); - const merges = list - .filter((i) => i.kind === "pr" && i.state === "merged") - .map((pr) => ({ pr, item: itemOf(pr, roadmap) })) - // What players see, and anything that moves a roadmap item. - .filter(({ pr, item }) => item || !pr.labels.includes("skip-release")) - .map(({ pr, item }) => ({ - id: `pr-${pr.n}`, - kind: "merge", - date: pr.at, - title: pr.title, - url: pr.url, - number: pr.n, - ...(item ? { item, item_title: items.get(item)?.title } : {}), - })); - const released = ours.map((r) => ({ - id: `release-${r.tag_name}`, - kind: "release", - date: r.published_at || "", - title: r.name || r.tag_name, - plain: parts(r).line, - url: r.html_url, - tag: r.tag_name, - })); - const shipped = roadmap.items - .filter((i) => i.status === "shipped") - .map((i) => ({ - id: `shipped-${i.id}`, - kind: "shipped", - date: published.get(i.shipped_in) || generated, - title: i.title, - plain: i.plain, - url: `${HUB}#${i.id}`, - item: i.id, - item_title: i.title, - shipped_in: i.shipped_in, - post: i.post || i.plain, - announce: Boolean(i.announce), - })); - // A week's summary is dated at the end of its week, after that week's releases. - const weekly = (roadmap.journal || []).map((w) => ({ - id: `weekly-${w.week}`, - kind: "weekly", - date: new Date(Date.parse(w.date) + 7 * 864e5 - 1000).toISOString(), - title: w.title, - plain: w.plain, - dev: w.dev, - url: `${HUB}#week-${w.week}`, - })); - const entries = [...merges, ...released, ...shipped, ...weekly].sort((a, b) => b.date.localeCompare(a.date) || a.id.localeCompare(b.id)); - return { format: "horizon-journal", version: 2, generated, url: `${HUB}#journal`, entries: entries.slice(0, FEED) }; -} - -const KIND = { release: "Game", launcher: "Launcher", merged: "Merged", week: "This week" }; - -function changesHtml(entry, roadmap) { - if (!entry.changes.length) return ""; - const line = (c) => `
  • ${inline(c.text)}${c.prs.length ? ` ${c.prs.map((n) => `#${n}`).join(" ")}` : ""}
  • `; - // Simple: what is new, then how many fixes. Technical: every change, under the roadmap item it moves. - const added = entry.changes.filter((c) => c.section !== "fixed"); - const fixes = entry.changes.length - added.length; - const simple = `
    ${added.length ? `
      ${added.map((c) => `
    • ${inline(c.text)}
    • `).join("")}
    ` : ""}${fixes ? `

    ${added.length ? "And " : ""}${fixes} fix${fixes === 1 ? "" : "es"}.

    ` : ""}
    `; - const groups = new Map(); - for (const c of entry.changes) groups.set(c.item || "", [...(groups.get(c.item || "") || []), c]); - const order = [...groups.keys()].sort((a, b) => (a === "") - (b === "")); - const dev = `
    ${order - .map((id) => { - const item = roadmap.items.find((i) => i.id === id); - return `

    ${item ? `${escape(item.title)}` : "Other changes"}

      ${groups.get(id).map(line).join("")}
    `; - }) - .join("")}
    `; - return simple + dev; -} - -export function journalHtml(entries, roadmap) { - return entries - .map((e) => { - const shipped = e.shipped.map((id) => roadmap.items.find((i) => i.id === id)).filter(Boolean); - const items = [...new Set(e.changes.map((c) => c.item).filter(Boolean))].map((id) => roadmap.items.find((i) => i.id === id)).filter(Boolean); - return `
    - -

    ${e.url ? `${escape(e.title)}` : escape(e.title)}

    - ${e.kind === "week" ? `

    ${inline(e.plain)}

    ${inline(e.dev.replace(/(^|[^\w[/`])#(\d+)\b/g, (_, b, n) => `${b}[#${n}](${issueUrl(n)})`))}

    ` : ""} - ${shipped.length ? `

    Shipped: ${shipped.map((i) => `${escape(i.title)}`).join(", ")}

    ` : ""} - ${items.length ? `

    ${items.map((i) => `${escape(i.title)}`).join("")}

    ` : ""} - ${changesHtml(e, roadmap)} -
    `; - }) - .join("\n"); -} - -// --- Contribute: the suggestion form's items and the contributors ------------------------------------------------ - -// The suggestion form's "About" choices: the items still to come. -export function suggestItemsHtml(roadmap) { - const open = roadmap.items.filter((i) => i.status !== "shipped"); - return [``] - .concat(open.map((i) => ``)) - .join(""); -} - -// The contributors, from GitHub (contributors.js's list), with the Discord name of those who linked their accounts -// on Kira and agreed to be credited (Kira's /contributors.json: { contributors: [{ github, discord }] }). -export function creditFrom(github, kira) { - const discord = new Map( - (Array.isArray(kira?.contributors) ? kira.contributors : []) - .filter((c) => c && typeof c.github === "string" && typeof c.discord === "string") - .map((c) => [c.github.toLowerCase(), c.discord]), - ); - return github.map((c) => ({ ...c, discord: discord.get(c.login.toLowerCase()) || "" })); -} - -export function creditHtml(list) { - return list - .map((c) => { - const src = avatar(c.avatar_url); - const count = `${c.contributions.toLocaleString("en-US")} ${c.contributions === 1 ? "commit" : "commits"}`; - return `
  • ${src ? `` : ``}${escape(c.login)}${count}${c.discord ? ` · ${escape(c.discord)} on Discord` : ""}
  • `; - }) - .join(""); -} diff --git a/website/src/hub.test.mjs b/website/src/hub.test.mjs deleted file mode 100644 index 4213d8a87..000000000 --- a/website/src/hub.test.mjs +++ /dev/null @@ -1,159 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { boardFrom, boardHtml, creditFrom, creditHtml, fixesIn, itemOf, journalFrom, journalHtml, journalJson, overviewHtml, progress, roadmapHtml, slimIssue, suggestItemsHtml } from "./hub.js"; - -const roadmap = JSON.parse(readFileSync(new URL("../data/roadmap.json", import.meta.url), "utf8")); - -const pr = (number, extra = {}) => ({ number, title: `PR ${number}`, state: "open", pull_request: { merged_at: null }, user: { login: "a" }, labels: [], assignees: [], body: "", ...extra }); -const issue = (number, extra = {}) => ({ number, title: `Issue ${number}`, state: "open", user: { login: "b" }, labels: [{ name: "bug" }], assignees: [], ...extra }); - -test("pull requests and issues reduce to their hub state", () => { - assert.deepEqual(fixesIn("Fixes #12, #13 and closes #4. Mentions #9."), [4, 12, 13]); - const merged = slimIssue(pr(5, { state: "closed", pull_request: { merged_at: "2030-01-01T00:00:00Z" }, body: "Fixes #3" })); - assert.equal(merged.kind, "pr"); - assert.equal(merged.state, "merged"); - assert.deepEqual(merged.fixes, [3]); - assert.equal(slimIssue(issue(6, { state: "closed", state_reason: "not_planned" })).state, "dropped"); - assert.deepEqual(slimIssue(issue(7)).labels, ["bug"]); -}); - -test("an item's progress counts its pull requests and issues", () => { - const live = new Map([ - [1, slimIssue(pr(1))], - [2, slimIssue(pr(2, { draft: true }))], - [3, slimIssue(pr(3, { state: "closed", pull_request: { merged_at: "2030-01-01T00:00:00Z" } }))], - [4, slimIssue(issue(4))], - ]); - const item = { links: ["#1", "#2", "#3", "#4", "#99", "docs/x.md"] }; - assert.equal(progress(item, live), "1 pull request in review · 1 in progress · 1 merged · 1 issue open"); - assert.equal(progress(item, new Map()), ""); -}); - -test("the roadmap renders every item once, with both texts, in its status's section", () => { - const html = roadmapHtml(roadmap); - for (const item of roadmap.items) { - assert.equal(html.split(`id="${item.id}"`).length, 2, item.id); - } - assert.ok(html.indexOf('id="now"') < html.indexOf('id="next"')); - assert.ok(html.includes('class="hub-plain"') && html.includes('class="hub-dev"')); - // Shipped items say which release had them. - assert.ok(html.includes(`>${roadmap.items.find((i) => i.status === "shipped").shipped_in}<`)); -}); - -test("an item is its own anchor (hub#, as Kira links it) and takes no other id of the page", () => { - const week = { week: "2030-01", date: "2029-12-31", title: "A week", plain: "p", dev: "d" }; - const release = { tag_name: "v0.2.2", name: "0.2.2", published_at: "2030-01-02T00:00:00Z", html_url: "u", body: "" }; - const journal = journalHtml(journalFrom([release], [], { ...roadmap, journal: [week] }), roadmap); - const board = boardHtml(boardFrom([], roadmap), roadmap); - const page = readFileSync(new URL("./hub.html", import.meta.url), "utf8") + roadmapHtml(roadmap) + overviewHtml(roadmap) + board + journal; - const ids = [...page.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]); - assert.deepEqual(ids.filter((id, i) => ids.indexOf(id) !== i), []); - for (const item of roadmap.items) assert.ok(ids.includes(item.id), item.id); -}); - -test("the overview lists the focus items and never nests links", () => { - const html = overviewHtml(roadmap); - for (const item of roadmap.items.filter((i) => i.focus)) assert.ok(html.includes(`href="#${item.id}"`), item.id); - for (const row of html.match(/
  • [\s\S]*?<\/li>/g)) assert.equal(row.split(" { - const now = Date.parse("2030-01-10T00:00:00Z"); - const list = [ - issue(1), - issue(2), - issue(3, { assignees: [{ login: "x" }] }), - pr(10, { body: "Fixes #1" }), - pr(11, { body: "Fixes #2", draft: true }), - pr(12, { labels: [{ name: `roadmap:${roadmap.items[0].id}` }] }), - pr(13, { draft: true }), - pr(14, { state: "closed", pull_request: { merged_at: "2030-01-09T00:00:00Z" } }), - pr(15, { state: "closed", pull_request: { merged_at: "2029-12-01T00:00:00Z" } }), - ].map(slimIssue); - const board = boardFrom(list, roadmap, now); - const ids = (column) => board[column].map((c) => c.n); - assert.deepEqual(ids("todo"), []); - assert.deepEqual(ids("progress"), [2, 3, 13]); - assert.deepEqual(ids("review"), [1, 12]); - assert.deepEqual(ids("done"), [14]); - assert.deepEqual(board.review[0].prs, [10]); - assert.equal(board.review[1].item, roadmap.items[0].id); - assert.equal(itemOf(slimIssue(pr(16, { body: "Fixes #1438" })), roadmap), "end-city-banners"); -}); - -test("the journal groups a release's changes by roadmap item and lists what awaits a release", () => { - const releases = [ - { tag_name: "v0.2.2", name: "Horizon 0.2.2: Rich presence", published_at: "2030-01-02T00:00:00Z", html_url: "u2", body: "### New\n- Discord Rich Presence (#1787)\n\n### Fixed\n- A fix (#5, fixes #4)\n" }, - { tag_name: "launcher@0.2.1", name: "Horizon launcher 0.2.1", published_at: "2030-01-01T00:00:00Z", html_url: "u1", body: "### Fixed\n- Window (#6)\n" }, - ]; - const list = [ - pr(1788, { state: "closed", pull_request: { merged_at: "2030-01-03T00:00:00Z" } }), - pr(7, { state: "closed", pull_request: { merged_at: "2030-01-03T00:00:00Z" }, labels: [{ name: "skip-release" }] }), - ].map(slimIssue); - const entries = journalFrom(releases, list, { ...roadmap, journal: [] }); - assert.deepEqual(entries.map((e) => e.id), ["next-release", "v0.2.2", "launcher@0.2.1"]); - assert.deepEqual(entries[0].changes.map((c) => [c.prs, c.item]), [[[1788], "discord-presence"]]); - assert.deepEqual(entries[1].changes.map((c) => [c.section, c.text, c.item]), [["new", "Discord Rich Presence", "discord-presence"], ["fixed", "A fix", null]]); - const html = journalHtml(entries, roadmap); - assert.ok(html.includes('href="#discord-presence"')); - assert.ok(html.includes("And 1 fix.")); -}); - -test("journal.json is the flat feed Kira reads: merges, releases, shipped items, weeks, ids that never change", () => { - const releases = [ - { tag_name: "v0.2.48", name: "Horizon 0.2.48", published_at: "2030-01-02T00:00:00Z", html_url: "https://github.com/ludovic111/horizon/releases/tag/v0.2.48", body: "### New\n- Packs (#1)\n" }, - { tag_name: "nightly-1", name: "Nightly", published_at: "2030-01-03T00:00:00Z", html_url: "u", body: "" }, - ]; - const list = [ - pr(1788, { state: "closed", pull_request: { merged_at: "2030-01-04T00:00:00Z" }, html_url: "https://github.com/ludovic111/horizon/pull/1788" }), - pr(7, { state: "closed", pull_request: { merged_at: "2030-01-04T00:00:00Z" }, labels: [{ name: "skip-release" }] }), - pr(8, { state: "closed", pull_request: { merged_at: "2030-01-04T00:00:00Z" }, labels: [{ name: "skip-release" }, { name: "roadmap:lean-code" }] }), - pr(9), - ].map(slimIssue); - const week = { week: "2030-01", date: "2029-12-31", title: "A week", plain: "p", dev: "d" }; - const feed = journalJson(releases, list, { ...roadmap, journal: [week] }, "2030-01-05T00:00:00Z"); - const byId = new Map(feed.entries.map((e) => [e.id, e])); - // A merged pull request, with its roadmap item; skip-release ones only when they move an item; open ones never. - assert.deepEqual(byId.get("pr-1788"), { id: "pr-1788", kind: "merge", date: "2030-01-04T00:00:00Z", title: "PR 1788", url: "https://github.com/ludovic111/horizon/pull/1788", number: 1788, item: "discord-presence", item_title: "Discord Rich Presence" }); - assert.ok(!byId.has("pr-7") && byId.get("pr-8").item === "lean-code" && !byId.has("pr-9")); - // Game and launcher releases only. - assert.equal(byId.get("release-v0.2.48").tag, "v0.2.48"); - assert.ok(!byId.has("release-nightly-1")); - // Every shipped item, dated by its release when the feed has it, with its post and the announce flag. - const shipped = roadmap.items.filter((i) => i.status === "shipped"); - for (const item of shipped) { - const e = byId.get(`shipped-${item.id}`); - assert.equal(e.kind, "shipped"); - assert.equal(e.url, `https://playhorizon.dev/hub#${item.id}`); - assert.ok(e.post.length <= 240 && typeof e.announce === "boolean", item.id); - } - assert.equal(byId.get("shipped-server-resource-packs").date, "2030-01-02T00:00:00Z"); - assert.equal(byId.get("weekly-2030-01").date, "2030-01-06T23:59:59.000Z"); - // Newest first; every entry has what Kira needs, and links only where Kira shows them. - assert.deepEqual(feed.entries.map((e) => e.date), feed.entries.map((e) => e.date).sort().reverse()); - for (const e of feed.entries) { - assert.ok(e.id && ["merge", "release", "weekly", "shipped"].includes(e.kind) && e.date && e.title, e.id); - assert.match(e.url, /^https:\/\/(github\.com|playhorizon\.dev)\//, e.id); - } -}); - -test("items still to come take votes and suggestions; shipped ones don't", () => { - const html = roadmapHtml(roadmap); - for (const item of roadmap.items) { - assert.equal(html.includes(`data-vote="${item.id}"`), item.status !== "shipped", item.id); - assert.equal(suggestItemsHtml(roadmap).includes(`value="${item.id}"`), item.status !== "shipped", item.id); - } - // Hidden until the hub's server answers. - assert.ok(html.includes('aria-pressed="false" hidden>')); -}); - -test("the contributors carry the Discord name of those who agreed on Kira", () => { - const github = [{ login: "Alex", html_url: "https://github.com/Alex", avatar_url: "", contributions: 3 }, { login: "sam", html_url: "https://github.com/sam", avatar_url: "", contributions: 1 }]; - const kira = { contributors: [{ github: "alex", discord: "Alex " }, { github: 5 }, null] }; - const credit = creditFrom(github, kira); - assert.deepEqual(credit.map((c) => c.discord), ["Alex ", ""]); - const html = creditHtml(credit); - assert.ok(html.includes("3 commits · Alex <b> on Discord") && html.includes("1 commit")); - assert.deepEqual(creditFrom(github, "not json").map((c) => c.discord), ["", ""]); -}); diff --git a/website/src/index.html b/website/src/index.html deleted file mode 100644 index 3776733c6..000000000 --- a/website/src/index.html +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - Horizon: a Rust rewrite for Minecraft: Java Edition 26.3 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} - -
    -
    -
    -

    Minecraft: Java Edition 26.3Rust

    -

    Horizon: The game you know, rebuilt in Rust.

    -

    The Minecraft: Java Edition 26.3 client and its game logic, rewritten in Rust and checked against the vanilla game by {{HARNESSES}} differential harnesses.

    - -

    For Linux x86-64, macOS on Apple Silicon and Windows x64.

    -
    -
    -
    -
      -
    1. 01Matches vanilla{{PERCENT}}
    2. -
    3. 02Differential harnesses{{HARNESSES}}
    4. -
    5. 03Drawn by Horizon5 shots
    6. -
    7. 04Recently addedReleases
    8. - -
    -

    Unofficial. Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    - -
    -
    -

    01What it is

    -

    Horizon rebuilds Minecraft: Java Edition 26.3 in Rust, one subsystem at a time, and holds every piece to the same inputs as the vanilla game until the outputs are identical.

    -
    -
    - -

    Your own copy

    -

    Install Java Edition with the official launcher and play 26.3 once. Horizon builds its resources from those files on your machine, and never downloads or shares game files.

    -
    -
    - -

    Native on three platforms

    -

    Linux, macOS on Apple Silicon and Windows. Worlds come from a Rust port of the vanilla generators, drawn with vanilla's block models, lighting, sky and fog.

    -
    -
    - -

    Open source

    -

    Horizon's code is MIT. It contains no Minecraft code, assets or data; those belong to Mojang and Microsoft under the Minecraft EULA.

    -
    -
    -
    -
    - -
    -
    -
    -

    02In game

    -

    Drawn by Horizon

    -

    Captured in the Rust client itself, with vanilla assets from the player's own copy and no mods or shaders.

    -
    -
    -
    01A plains village
    -
    02Moonrise
    -
    03A crimson forest in the Nether
    -
    04A coral reef
    -
    05Survival at dusk
    -
    -
    -
    - -
    -
    -

    03Parity

    -
    -
    - -

    {{HARNESSES}} differential harnesses drive the same inputs through Horizon and through the vanilla client and server, and compare what comes out.

    -
    -
    -

    {{PARITY_HEADLINE}}

    -
    {{PARITY_INTRO}}
    -

    Last audited {{AUDITED}}{{PERCENT}} of what the harnesses comparefrom the README on main

    -
    -
    -
      - {{PARITY_CELLS}} -
    -

    Each subsystem scores 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified against vanilla by differential tests).

    -
    -
    - -
    -
    -
    -

    04The launcher

    -

    One small app, then play

    -

    The launcher installs Horizon, prepares it from your copy of the game and keeps both up to date.

    -
      -
    • One-click install

      Pick a release and it installs the build for your platform into its own folder, then prepares the resources from your Java Edition 26.3 install.

    • -
    • Updates on its own

      It looks for a new Horizon release when it starts and every four hours, and updates itself from the launcher's own releases.

    • -
    • Client or server

      Start the client, or host a server from the Servers page: create it, start it, watch players and the console, change its settings and keep backups.

    • -
    • Logs and crash reports

      The Settings page shows where the game's logs and crash reports are, and horizon-launcher logs prints them.

    • -
    • Modpacks and packs

      The Modpacks page reads a Modrinth or CurseForge pack and carries over the mods Horizon can run. The Mods page installs resource packs and modpacks from Modrinth.

    • -
    -
    - Download the launcher -

    {{LAUNCHER_META}}

    -
    -
    - -
    -
    - - - -
    -
    -
    The launcher's Play page: the version, Client and Server, the version picker and Play, the prepared resources and the Minecraft folder as a numbered list
    -
    Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics
    -
    -

    The launcher, and Horizon's graphics settings in the game: vanilla's menus, with Horizon's pages added the way mods add theirs.

    -
    -
    -
    - -
    -
    -
    -

    05Releases

    -

    Recently added

    -

    New versions come out many times a day, as changes land, and the launcher installs each one once it has passed its test. The newest, and what they added:

    -
    -
    - {{RECENT}} -
    -
    -
    - -
    -
    -

    06Community

    -

    Built in the open

    - -
    -

    Contributors

    -

    Everyone who has committed to Horizon on GitHub, with how many commits.

    -
      {{CONTRIBUTORS}}
    -
    -
    -
    -
    - -
    -
    - - - -
    - -
    - - -
    - - -
    - - - - diff --git a/website/src/main.js b/website/src/main.js deleted file mode 100644 index 72e8577a5..000000000 --- a/website/src/main.js +++ /dev/null @@ -1,265 +0,0 @@ -// The page's behaviour: the download buttons for this platform, the latest releases, the -// links and contributors, how many play now, the navigation, the menus' tabs, the lightbox -// and the motion. -// Everything degrades to the baked-in content when GitHub can't be reached. -import { escape } from "./markdown.js"; -import { recentHtml } from "./releases.js"; -import { contributorsFrom, contributorsHtml } from "./contributors.js"; -import { isGame, isLauncher as isLauncherTag, launcherVersion, newestFirst } from "./versions.js"; -import { cached, get, setUpMenu, setUpTheme, slim } from "./chrome.js"; - -const REPO = "ludovic111/horizon"; -const RELEASES_PAGE = `https://github.com/${REPO}/releases?q=launcher&expanded=true`; -const baked = JSON.parse(document.getElementById("baked").textContent); -const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; - -// --- GitHub -------------------------------------------------------------------------- - -const isLauncher = (r) => isLauncherTag(r.tag_name) && !r.prerelease; - -// The newest 100 releases (GitHub's most per page). The newest game release that is not a pre-release (the old -// Alpha channel's, docs/release-flow.md) and the newest launcher release can be older than all of them. The game -// release is then GitHub's "latest" (pre-releases and launcher releases never are); the launcher release is looked up by its -// tag (the newest launcher@ tag with a published release, or launcher-v before 0.2: docs/versioning.md). -async function loadReleases() { - const list = (await get(`https://api.github.com/repos/${REPO}/releases?per_page=100`)).filter((r) => !r.draft).map(slim); - if (!list.some((r) => isGame(r.tag_name) && !r.prerelease)) { - const latest = await get(`https://api.github.com/repos/${REPO}/releases/latest`).catch(() => null); - if (latest && !latest.draft) list.push(slim(latest)); - } - if (list.some(isLauncher)) return list; - let tags = []; - for (const prefix of ["launcher@", "launcher-v"]) { - if (tags.length) break; - const refs = await get(`https://api.github.com/repos/${REPO}/git/matching-refs/tags/${prefix}`).catch(() => []); - tags = refs.map((r) => r.ref.replace("refs/tags/", "")).filter(isLauncherTag).sort(newestFirst); - } - for (const tag of tags.slice(0, 3)) { - const release = await get(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`).catch(() => null); - if (release && !release.draft) return [...list, slim(release)]; - } - return list; -} - -const releases = cached("horizon:releases", loadReleases).catch(() => baked.releases); - -async function setUpContributors() { - let list = baked.contributors || []; - try { - list = contributorsFrom(await cached("horizon:contributors", () => get(`https://api.github.com/repos/${REPO}/contributors?per_page=100`))); - } catch {} - if (list.length) document.getElementById("contributors-list").innerHTML = contributorsHtml(list); - // With nobody to show, the block and its menu entries go, as an empty link's card does. - document.querySelectorAll("[data-contributors]").forEach((el) => (el.hidden = !list.length)); -} - -// --- Download ------------------------------------------------------------------------ - -function platform() { - const ua = navigator.userAgent; - const hint = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ""; - if (/iPhone|iPad|iPod|Android/i.test(ua) || (navigator.maxTouchPoints > 1 && /Mac/.test(hint))) return null; - if (/Win/i.test(hint) || /Windows/.test(ua)) return { key: "windows-x64", label: "Windows x64" }; - if (/Mac/i.test(hint) || /Mac OS X/.test(ua)) return { key: "macos-arm64", label: "macOS on Apple Silicon" }; - if (/Linux|X11/i.test(hint) || /Linux/.test(ua)) return { key: "linux-x86_64", label: "Linux x86-64" }; - return null; -} - -async function setUpDownload() { - const os = platform(); - const list = await releases; - const launcher = list.filter(isLauncher).sort((a, b) => newestFirst(a.tag_name, b.tag_name))[0]; - if (!launcher) return; - const version = launcherVersion(launcher.tag_name); - const asset = os && launcher.assets.find((a) => a.name.includes(os.key)); - const others = `Other platforms`; - document.querySelectorAll("[data-download]").forEach((a) => (a.href = asset ? asset.browser_download_url : launcher.html_url || RELEASES_PAGE)); - const mb = asset && asset.size ? ` · ${(asset.size / 1e6).toFixed(0)} MB` : ""; - const meta = asset - ? `For ${escape(os.label)} · launcher ${escape(version)}${mb} · ${others}` - : `Launcher ${escape(version)} for Linux x86-64, macOS on Apple Silicon and Windows x64. It runs on a computer, not on phones or tablets.`; - document.querySelectorAll("[data-download-meta]").forEach((p) => (p.innerHTML = meta)); -} - -// --- Recently added and the links from main --------------------------------- - -// "Recently added": the newest game releases. -async function setUpRecent() { - const html = recentHtml((await releases).filter((r) => isGame(r.tag_name))); - if (html) document.getElementById("recent-releases").innerHTML = html; -} - -async function setUpLinks() { - let links = baked.links; - try { - const remote = await cached("horizon:links", () => get(`https://raw.githubusercontent.com/${REPO}/main/launcher/links.json`, "text").then(JSON.parse)); - links = { ...links }; - for (const key of Object.keys(links)) if (typeof remote[key] === "string") links[key] = remote[key].trim(); - } catch {} - document.querySelectorAll("[data-link]").forEach((a) => { - const url = links[a.dataset.link]; - // An empty address hides its card, as the launcher hides its button. - if (!url) a.hidden = true; - else if (/^https:\/\//.test(url)) a.href = url; - }); -} - -// --- Playing now --------------------------------------------------------------------- - -const TELEMETRY = "https://telemetry.playhorizon.dev"; - -// How many play right now, from the games' anonymous "I'm playing" pings (Privacy explains them). The -// server rounds it and makes it again at most every 15 seconds; the row stays hidden while nobody plays -// or the server doesn't answer. -async function setUpLive() { - const row = document.querySelector("[data-live]"); - if (!row) return; - const update = async () => { - try { - const res = await fetch(`${TELEMETRY}/v1/live`, { signal: AbortSignal.timeout(3000) }); - if (!res.ok) throw new Error(String(res.status)); - const { playing } = await res.json(); - if (!Number.isInteger(playing) || playing < 1) throw new Error("nobody"); - row.querySelector("[data-live-count]").textContent = playing.toLocaleString("en"); - row.hidden = false; - // It shows up after the others came in, so it skips their entrance. - row.classList.add("is-visible"); - } catch { - row.hidden = true; - } - }; - await update(); - setInterval(() => document.visibilityState === "visible" && update(), 60_000); -} - -// --- Navigation ---------------------------------------------------------------------- - -function setUpNav() { - // The home sections use the same root-relative links in every page's header. - const links = [...document.querySelectorAll('.nav-links a[href^="/#"]')]; - const sections = links.map((a) => document.querySelector(a.hash)); - - let current = null; - const update = () => { - const line = innerHeight * 0.4; - let index = -1; - sections.forEach((s, i) => { - if (s.getClientRects().length && s.getBoundingClientRect().top <= line) index = i; - }); - if (index !== current) { - current = index; - links.forEach((a, i) => (i === index ? a.setAttribute("aria-current", "true") : a.removeAttribute("aria-current"))); - } - }; - onScroll(update); - update(); -} - -// One rAF-throttled scroll listener for everything that follows the scroll. -const scrollers = []; -function onScroll(fn) { - scrollers.push(fn); -} -let queued = false; -const run = () => ((queued = false), scrollers.forEach((fn) => fn())); -addEventListener("scroll", () => !queued && ((queued = true), requestAnimationFrame(run)), { passive: true }); -addEventListener("resize", () => requestAnimationFrame(run)); - -// --- The menus' tabs ------------------------------------------------------------------ - -function setUpTabs() { - const list = document.querySelector(".tabs"); - const tabs = [...list.querySelectorAll('[role="tab"]')]; - const pill = list.querySelector(".tab-pill"); - const selected = () => tabs.find((t) => t.getAttribute("aria-selected") === "true"); - const place = (tab) => { - pill.style.width = `${tab.offsetWidth}px`; - pill.style.height = `${tab.offsetHeight}px`; - pill.style.transform = `translate(${tab.offsetLeft}px, ${tab.offsetTop}px)`; - }; - const choose = (tab, focus) => { - tabs.forEach((t) => { - const on = t === tab; - t.setAttribute("aria-selected", String(on)); - t.tabIndex = on ? 0 : -1; - const panel = document.getElementById(t.getAttribute("aria-controls")); - panel.classList.toggle("is-active", on); - panel.inert = !on; - panel.setAttribute("aria-hidden", String(!on)); - }); - place(tab); - if (focus) tab.focus(); - }; - tabs.forEach((tab, i) => { - tab.addEventListener("click", () => choose(tab)); - tab.addEventListener("keydown", (e) => { - const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; - if (step) choose(tabs[(i + step + tabs.length) % tabs.length], true); - if (e.key === "Home") choose(tabs[0], true); - if (e.key === "End") choose(tabs[tabs.length - 1], true); - }); - }); - choose(selected()); - addEventListener("resize", () => place(selected())); - document.fonts?.ready.then(() => place(selected())); -} - -function setUpLightbox() { - const dialog = document.querySelector(".lightbox"); - const big = dialog.querySelector("img"); - const caption = dialog.querySelector(".lightbox-caption"); - document.querySelectorAll(".zoom").forEach((button) => { - button.addEventListener("click", () => { - const img = button.querySelector("img"); - big.src = img.currentSrc.replace("-800.webp", ".webp"); - big.alt = img.alt; - caption.textContent = img.alt; - dialog.showModal(); - }); - }); - dialog.addEventListener("click", (e) => { - if (e.target === dialog) dialog.close(); - }); -} - -// --- Motion --------------------------------------------------------------------------- - -function setUpReveal() { - // Siblings that enter together come in one after another. - const stagger = (els, step) => els.forEach((el, i) => el.style.setProperty("--d", `${i * step}ms`)); - document.querySelectorAll(".facts, .features, .cards, .mosaic, .hero-index").forEach((group) => stagger([...group.children], 40)); - document.querySelectorAll(".hero .container, .section-head, .parity-count, .parity-copy, .launcher-copy").forEach((group) => stagger([...group.querySelectorAll(":scope > .reveal")], 60)); - stagger([...document.querySelectorAll(".system")], 40); - - const count = document.querySelector("[data-digits]"); - count.innerHTML = [...count.textContent].map((c) => `${escape(c)}`).join(""); - - const items = document.querySelectorAll(".reveal, .reveal-media, .systems"); - if (reducedMotion || !("IntersectionObserver" in window)) { - items.forEach((el) => el.classList.add("is-visible")); - return; - } - const observer = new IntersectionObserver( - (entries) => - entries.forEach((e) => { - if (e.isIntersecting) { - e.target.classList.add("is-visible"); - observer.unobserve(e.target); - } - }), - { rootMargin: "0px 0px -10% 0px" }, - ); - items.forEach((el) => observer.observe(el)); -} - -setUpReveal(); -setUpTheme(); -setUpNav(); -setUpMenu(); -setUpTabs(); -setUpLightbox(); -setUpDownload(); -setUpRecent(); -setUpLinks(); -setUpContributors(); -setUpLive(); diff --git a/website/src/markdown.js b/website/src/markdown.js deleted file mode 100644 index 1735f7304..000000000 --- a/website/src/markdown.js +++ /dev/null @@ -1,203 +0,0 @@ -// A small Markdown renderer for release notes, the README and the docs: headings, paragraphs, -// lists (nested by indentation), fenced code, quotes, rules, tables, and inline code, bold, -// italic and links. Everything is escaped first, so its output never carries raw HTML. -// Shared by build.mjs (Node) and main.js (browser). - -const REPO_BLOB = "https://github.com/ludovic111/horizon/blob/main/"; - -export function escape(text) { - return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); -} - -function href(url, site) { - url = url.trim(); - if (/^(https?:|mailto:)/i.test(url)) return url; - // The docs pages (`site`) link each other and their own headings; the README's anchors have no target here. - if (site && (/^\/(?!\/)/.test(url) || url.startsWith("#"))) return url; - if (url.startsWith("#")) return null; - if (/^[a-z]+:/i.test(url)) return null; // javascript: and the like - return REPO_BLOB + url.replace(/^\.?\//, ""); -} - -export function inline(text, site = false) { - const codes = []; - let out = text.replace(/`([^`]+)`/g, (_, code) => { - codes.push(code); - return `\u0000${codes.length - 1}\u0000`; - }); - const links = []; - out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => { - links.push([label, url]); - return `\u0001${links.length - 1}\u0001`; - }); - out = escape(out) - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, "$1$2") - .replace(/(^|[^\w])_([^_\s][^_]*)_(?!\w)/g, "$1$2") - .replace(/<(https?:\/\/[^\s&]+)>/g, (_, u) => `${u}`); - out = out.replace(/\u0001(\d+)\u0001/g, (_, i) => { - const [label, url] = links[i]; - const target = href(url, site); - // The label's code spans are already placeholders: put them back for its own pass. - const body = inline(label.replace(/\u0000(\d+)\u0000/g, (_, c) => `\`${codes[c]}\``), site); - return target ? `${body}` : body; - }); - return out.replace(/\u0000(\d+)\u0000/g, (_, i) => `${escape(codes[i])}`); -} - -// A table: a row of cells, a rule row (`| --- | :-: |`), then body rows, until a line that isn't a row. -const ROW = /^\s*\|.*\|\s*$/; -const RULE = /^\s*\|(\s*:?-+:?\s*\|)+\s*$/; - -/** A row's cells: split on `|` outside code spans; `\|` is a literal bar. */ -function cells(row) { - const s = row.trim().slice(1, -1); - const out = [""]; - let code = false; - for (let i = 0; i < s.length; i++) { - if (s[i] === "\\" && s[i + 1] === "|") out[out.length - 1] += s[++i]; - else if (s[i] === "|" && !code) out.push(""); - else { - if (s[i] === "`") code = !code; - out[out.length - 1] += s[i]; - } - } - return out.map((c) => c.trim()); -} - -function table(lines, start, site) { - const row = (line, tag) => `${cells(line).map((c) => `<${tag}>${inline(c, site)}`).join("")}`; - let i = start + 2; - const body = []; - while (i < lines.length && ROW.test(lines[i])) body.push(row(lines[i++], "td")); - return [`
    ${row(lines[start], "th")}${body.join("")}
    `, i]; -} - -const indentOf = (line) => line.match(/^ */)[0].length; -const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; - -function list(lines, start, site) { - // Collects the items at this list's indentation; deeper lines belong to the item above. - const base = indentOf(lines[start]); - const ordered = /\d/.test(lines[start].match(ITEM)[2]); - const items = []; - let i = start; - while (i < lines.length) { - const line = lines[i]; - if (!line.trim()) { - const next = lines.slice(i + 1).find((l) => l.trim()); - if (next && ITEM.test(next) && indentOf(next) >= base) { - i++; - continue; - } - break; - } - const m = line.match(ITEM); - if (m && indentOf(line) === base) { - items.push({ text: [m[3]], children: [] }); - } else if (m && indentOf(line) > base) { - const [html, end] = list(lines, i, site); - items[items.length - 1].children.push(html); - i = end; - continue; - } else if (indentOf(line) > base && items.length) { - items[items.length - 1].text.push(line.trim()); - } else { - break; - } - i++; - } - const tag = ordered ? "ol" : "ul"; - const body = items.map((it) => `
  • ${inline(it.text.join(" "), site)}${it.children.join("")}
  • `).join(""); - return [`<${tag}>${body}`, i]; -} - -const slug = (text) => - text - .toLowerCase() - .replace(/`/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - -/** - * Renders Markdown; `shift` moves headings down (a `#` becomes `h{1 + shift}`). - * `site` is for the docs pages: links to the site's own pages and anchors stay as they are, `##` and `###` - * headings get an id (and are pushed onto `headings`, for a table of contents), and a line holding only - * `![caption](/media/…)` becomes a figure. - */ -export function markdown(source, { shift = 0, site = false, headings = null } = {}) { - const lines = source.replace(/\r\n?/g, "\n").split("\n"); - const out = []; - let i = 0; - while (i < lines.length) { - const line = lines[i]; - if (!line.trim() || /^\s*<\/?[a-z][^>]*>\s*$/i.test(line)) { - i++; // blank lines and lone HTML tags (the README's layout tables) are skipped - continue; - } - let m; - if ((m = line.match(/^(#{1,6})\s+(.*?)\s*#*$/))) { - const level = Math.min(6, m[1].length + shift); - let id = ""; - if (site && (m[1].length === 2 || m[1].length === 3)) { - const base = slug(m[2]); - id = base; - for (let n = 2; headings && headings.some((h) => h.id === id); n++) id = `${base}-${n}`; - headings?.push({ level: m[1].length, text: m[2].replace(/`/g, ""), id }); - } - out.push(`${inline(m[2], site)}`); - i++; - } else if (site && (m = line.match(/^!\[([^\]]*)\]\((\/media\/[\w./-]+)\)\s*$/))) { - out.push(`
    ${escape(m[1])}
    ${inline(m[1], true)}
    `); - i++; - } else if ((m = line.match(/^\s*(```|~~~)/))) { - const fence = m[1]; - const code = []; - i++; - while (i < lines.length && !lines[i].trim().startsWith(fence)) code.push(lines[i++]); - i++; - out.push(`
    ${escape(code.join("\n"))}
    `); - } else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { - out.push("
    "); - i++; - } else if (ROW.test(line) && RULE.test(lines[i + 1] || "")) { - const [html, end] = table(lines, i, site); - out.push(html); - i = end; - } else if (ITEM.test(line)) { - const [html, end] = list(lines, i, site); - out.push(html); - i = end; - } else if (line.startsWith(">")) { - const quote = []; - while (i < lines.length && lines[i].startsWith(">")) quote.push(lines[i++].replace(/^>\s?/, "")); - out.push(`
    ${markdown(quote.join("\n"), { shift, site })}
    `); - } else { - const para = []; - while ( - i < lines.length && - lines[i].trim() && - !/^(#{1,6}\s|>|\s*```|\s*~~~)/.test(lines[i]) && - !ITEM.test(lines[i]) - ) { - para.push(lines[i++].trim()); - } - out.push(`

    ${inline(para.join(" "), site)}

    `); - } - } - return out.join("\n"); -} - -/** The body of the section titled `title` (any heading level), up to the next heading at its level or above. */ -export function section(source, title) { - const lines = source.replace(/\r\n?/g, "\n").split("\n"); - const level = (l) => (l.match(/^(#{1,6})\s/) || [, ""])[1].length; - const start = lines.findIndex((l) => level(l) > 0 && l.replace(/^#+/, "").trim() === title); - if (start < 0) return null; - const depth = level(lines[start]); - const end = lines.findIndex((l, i) => i > start && level(l) > 0 && level(l) <= depth); - return lines - .slice(start + 1, end < 0 ? undefined : end) - .join("\n") - .trim(); -} diff --git a/website/src/markdown.test.mjs b/website/src/markdown.test.mjs deleted file mode 100644 index 171348e45..000000000 --- a/website/src/markdown.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -// node --test src/: the Markdown the docs pages and release notes are rendered from. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { markdown } from "./markdown.js"; - -test("a table renders with its header, cells and inline Markdown", () => { - const html = markdown("| Way in | Status |\n| --- | :-: |\n| Rust | `cargo horizon-mod` |\n| Lua | planned \\| soon |\n\nAfter."); - assert.equal( - html, - '
    ' + - "
    Way inStatus
    Rustcargo horizon-mod
    Luaplanned | soon
    \n

    After.

    ", - ); -}); - -test("a bar inside a code span stays in its cell", () => { - assert.match(markdown("| a | b |\n|---|---|\n| `x | y` | z |"), /x \| y<\/code><\/td>z<\/td>/); -}); - -test("rows without a rule line are a paragraph", () => { - assert.equal(markdown("| a | b |\n| c | d |"), "

    | a | b | | c | d |

    "); -}); - -test("a link whose label is code keeps it", () => { - assert.equal(markdown("See [`AGENTS.md`](/docs/agents).", { site: true }), '

    See AGENTS.md.

    '); -}); diff --git a/website/src/mods-page.js b/website/src/mods-page.js deleted file mode 100644 index 121ba8067..000000000 --- a/website/src/mods-page.js +++ /dev/null @@ -1,66 +0,0 @@ -// /mods and /mods/: the registry's list (kind filter, search) and a project's page. The pages are baked from -// the index at build time; this fetches the published index again so a project merged since shows at once. -import { INDEX_URL, KINDS, listHtml, matches, projectHtml, readIndex } from "./mods.js"; -import { cached, setUpMenu, setUpTheme } from "./chrome.js"; - -const root = document.getElementById("mods"); -const baked = readIndex(JSON.parse(document.getElementById("mods-index").textContent || "null")) || []; -let projects = baked; -let kind = "all"; -let text = ""; - -const id = (location.pathname.match(/^\/mods\/([a-z0-9_]+)\/?$/) || [])[1]; - -function showList() { - const list = root.querySelector("#mod-list"); - if (!list) return; - const shown = projects.filter((p) => matches(p, kind, text)); - list.innerHTML = listHtml(shown); - root.querySelector("[data-mod-count]").textContent = `${shown.length} of ${projects.length}`; - root.querySelectorAll("[data-kind-filter]").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.kindFilter === kind))); -} - -function showProject() { - const p = projects.find((x) => x.id === id); - const page = root.querySelector(".mod-page, .mod-missing"); - if (!page) return; - if (p) { - page.outerHTML = projectHtml(p); - document.title = `${p.name} · Horizon`; - } else { - page.outerHTML = `

    Not in the registry

    No project is called ${id} here. It may not be published yet, or it was taken down.

    Every mod
    `; - } -} - -function render() { - if (id) showProject(); - else showList(); -} - -if (!id) { - root.querySelectorAll("[data-kind-filter]").forEach((b) => - b.addEventListener("click", () => { - kind = b.dataset.kindFilter; - showList(); - }), - ); - root.querySelector("#mod-search")?.addEventListener("input", (e) => { - text = e.target.value; - showList(); - }); - const wanted = new URLSearchParams(location.search).get("kind"); - if (KINDS.some(([k]) => k === wanted)) kind = wanted; -} - -setUpTheme(); -setUpMenu(); -render(); -cached("horizon:mod-index", () => fetch(INDEX_URL).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))))) - .then((index) => { - const fresh = readIndex(index); - if (fresh) { - projects = fresh; - render(); - } - }) - .catch(() => {}); diff --git a/website/src/mods-submit.js b/website/src/mods-submit.js deleted file mode 100644 index c1024495b..000000000 --- a/website/src/mods-submit.js +++ /dev/null @@ -1,80 +0,0 @@ -// /mods/submit: signs in with GitHub through the registry's service, then sends the page and the file. The service -// checks the file and answers with the pull request it opened, or with what to fix. -import { API, entryFrom } from "./mods.js"; -import { setUpMenu, setUpTheme } from "./chrome.js"; -import { escape } from "./markdown.js"; - -const auth = document.getElementById("submit-auth"); -const form = document.getElementById("submit-form"); -const result = document.getElementById("submit-result"); -let csrf = ""; -// The form's own controls by name (form.id and form.name are the form's attributes). -const control = (name) => form.elements.namedItem(name); - -function signedOut(message) { - auth.innerHTML = `

    ${message}

    Sign in with GitHub

    It only reads your public profile: your GitHub name goes on the pull request and in the project's owners.

    `; - form.hidden = true; -} - -async function start() { - let me; - try { - const r = await fetch(`${API}/me`, { credentials: "same-origin" }); - if (r.status === 401) return signedOut("Sign in to submit: reviewers and updates go through your GitHub account."); - if (!r.ok) throw new Error(String(r.status)); - me = await r.json(); - } catch { - auth.innerHTML = `

    The form isn't open right now. You can open a pull request by hand.

    `; - return; - } - csrf = me.csrf; - auth.innerHTML = `

    Signed in as ${escape(me.login)}.

    `; - document.getElementById("sign-out").addEventListener("click", async () => { - await fetch(`${API}/auth/logout`, { method: "POST", credentials: "same-origin" }); - signedOut("Signed out."); - }); - form.hidden = false; -} - -// A file named -. fills in the two fields and the kind. -form.querySelector("#f-file").addEventListener("change", (e) => { - const name = e.target.files[0]?.name || ""; - const m = name.match(/^([a-z0-9_]{1,64})-([A-Za-z0-9][A-Za-z0-9._-]*)\.(hmod|hpack|zip)$/); - const kind = { hmod: "mod", hpack: "modpack", zip: "resourcepack" }[name.split(".").pop()]; - if (kind) control("kind").value = kind; - if (m) { - if (!control("id").value) control("id").value = m[1]; - if (!control("version").value) control("version").value = m[2]; - } -}); - -form.addEventListener("submit", async (e) => { - e.preventDefault(); - const send = form.querySelector("#f-send"); - const values = Object.fromEntries(["id", "kind", "name", "summary", "description", "authors", "license", "source", "icon", "key", "version", "game", "changelog"].map((k) => [k, control(k).value])); - const sig = form.querySelector("#f-signature").files[0]; - values.signature = sig ? await sig.text() : ""; - const body = new FormData(); - body.append("entry", JSON.stringify(entryFrom(values))); - body.append("file", form.querySelector("#f-file").files[0]); - send.disabled = true; - result.innerHTML = `

    Checking the file and opening the pull request…

    `; - try { - const r = await fetch(`${API}/submit`, { method: "POST", body, credentials: "same-origin", headers: { "X-CSRF-Token": csrf } }); - const json = await r.json().catch(() => ({})); - if (r.ok && /^https:\/\/github\.com\//.test(json.pull_request || "")) { - result.innerHTML = `

    Submitted. Follow the review in its pull request: the checks' report is there, and reviewers answer there.

    `; - form.reset(); - } else { - result.innerHTML = `

    ${escape(json.error || `The service answered ${r.status}.`)}

    `; - } - } catch { - result.innerHTML = `

    The service didn't answer. Try again in a minute.

    `; - } finally { - send.disabled = false; - } -}); - -setUpTheme(); -setUpMenu(); -start(); diff --git a/website/src/mods.html b/website/src/mods.html deleted file mode 100644 index 0c0e5eea2..000000000 --- a/website/src/mods.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - {{MODS_TITLE}} · Horizon - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} -
    -
    -{{MODS_MAIN}} -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    -{{SITE_FOOTER}} - - - diff --git a/website/src/mods.js b/website/src/mods.js deleted file mode 100644 index 414db7396..000000000 --- a/website/src/mods.js +++ /dev/null @@ -1,189 +0,0 @@ -// Horizon's registry on the site (/mods): the list with its kind filter and search, and a page per project. -// Shared by build.mjs (baked into the pages) and mods-page.js (refreshed from the published index). -// The index's format is docs/api/mod-index.md in the repository. -import { markdown, escape } from "./markdown.js"; - -export const INDEX_URL = "https://ludovic111.github.io/horizon-mods/index.json"; -const REGISTRY = "https://github.com/ludovic111/horizon-mods"; -export const SUBMIT = "/mods/submit"; -// The submission form's service (crates/horizon-mod-registry), behind the site's own address. -export const API = "/api/mods"; -export const SCHEMA = 1; - -// The kinds the site shows, in the filter's order. Kinds a newer index adds are left out until the site knows them. -export const KINDS = [ - ["mod", "Mods"], - ["modpack", "Modpacks"], - ["resourcepack", "Resource packs"], - ["shader", "Shader packs"], -]; -const KIND_NAMES = { mod: "Mod", modpack: "Modpack", resourcepack: "Resource pack", shader: "Shader pack" }; - -// What a capability lets a mod do, as the game and the launcher say it (horizon_mods::Capability::describe). -export const CAPABILITIES = { - "game-read": "see your position, health and the time", - chat: "show you chat messages", - hud: "draw text on your screen", - storage: "save its own settings to disk", - registries: "add blocks and items to your worlds", -}; - -// The index's projects this site can show, or null when it isn't an index it reads. -export function readIndex(index) { - if (!index || typeof index !== "object" || index.schema > SCHEMA || !Array.isArray(index.projects)) return null; - const known = new Set(KINDS.map(([k]) => k)); - return index.projects.filter((p) => known.has(p.kind) && /^[a-z0-9_]{1,64}$/.test(p.id) && p.versions?.length); -} - -const https = (url) => (typeof url === "string" && url.startsWith("https://") ? url : ""); -const size = (bytes) => (bytes >= 1 << 20 ? `${(bytes / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`); -const trust = (p) => - p.key ? `Signed` : `Unsigned`; -const initial = (p) => escape((p.name || p.id).trim().charAt(0).toUpperCase()); -const icon = (p) => - https(p.icon) ? `` : ``; - -export function matches(p, kind, text) { - if (kind !== "all" && p.kind !== kind) return false; - const hay = `${p.id} ${p.name} ${p.summary} ${(p.authors || []).join(" ")}`.toLowerCase(); - return text.toLowerCase().split(/\s+/).filter(Boolean).every((w) => hay.includes(w)); -} - -// The list: a row per project, newest version and kind on the right. -export function listHtml(projects) { - if (!projects.length) return `

    Nothing here yet. Submit yours.

    `; - return projects - .map( - (p) => ` - ${icon(p)} - ${escape(p.name)}${escape(p.summary || "")} - ${escape(KIND_NAMES[p.kind] || p.kind)}${escape(p.versions[0].version)} · ${escape((p.authors || []).join(", "))} -`, - ) - .join("\n"); -} - -function installHtml(p, v) { - const file = escape(v.file.name); - const cli = `horizon-launcher mods install --source horizon ${p.id}`; - const where = { - mod: `put it in horizon-mods/ beside options.txt in the game folder, then turn it on in the game: Options > Mods`, - resourcepack: `put it in resourcepacks/ beside options.txt, then turn it on in Options > Resource Packs`, - modpack: `a modpack lists registry mods: install them one by one, or wait for the launcher to install modpacks`, - shader: `Horizon's own shader packs aren't installed yet`, - }[p.kind]; - return `
      -
    1. In the launcher: Mods, pick Horizon beside Modrinth, find ${escape(p.name)} and press Install. It checks the file's checksum${p.key ? " and signature" : ""} first.
    2. -
    3. From a terminal or an agent: ${escape(cli)}
    4. -
    5. By hand: download ${file} (${size(v.file.size)}), check its SHA-256 below, and ${where}.
    6. -
    `; -} - -// GitHub's rule for a login: letters, digits and single dashes, at most 39. -const LOGIN = /^(?!-)(?!.*--)[A-Za-z0-9-]{1,39}(? LOGIN.test(o)); - if (!owners.length) return ""; - const links = owners - .map((o) => `@${o}`) - .join(" "); - return `

    Published by ${links}

    `; -} - -// One project's page. -export function projectHtml(p) { - const v = p.versions[0]; - const caps = v.capabilities || []; - const capsHtml = caps.length - ? `
      ${caps.map((c) => `
    • May ${escape(CAPABILITIES[c] || c)}
    • `).join("")}
    ` - : `

    It asks for nothing beyond writing its own log.

    `; - const requires = (v.requires || []).length - ? `

    Its mods

    ` - : ""; - const versions = p.versions - .map((x) => `
  • ${escape(x.version)} ${escape(x.published || "")} · for ${escape(x.game)}${x.api ? ` · API ${escape(x.api)}` : ""}${x.changelog ? `
    ${markdown(x.changelog)}
    ` : ""}
  • `) - .join(""); - return `
    -
    ${icon(p)}

    ${escape(KIND_NAMES[p.kind] || p.kind)}${escape((p.authors || []).join(", "))}

    ${ownersHtml(p)} -

    ${escape(p.name)}

    ${escape(p.summary || "")}

    -

    ${trust(p)} ${escape(p.license)} ${escape(v.version)}${v.side ? ` ${escape(v.side)}` : ""}

    - ${p.description ? `
    ${markdown(p.description)}
    ` : ""} - ${p.kind === "mod" ? `

    What it may do

    ${capsHtml}

    Mods run in a sandbox with no file, network or clock access. The game refuses a mod whose code uses more than this list.

    ` : ""} - ${requires} -

    Install

    - ${installHtml(p, v)} -

    File

    -
    Name
    ${escape(v.file.name)}
    Size
    ${size(v.file.size)}
    SHA-256
    ${escape(v.file.sha256)}
    ${p.key ? `
    Signing key
    ${escape(p.key)}
    ` : ""}
    -

    Versions

    -
      ${versions}
    - -
    `; -} - -// /mods/submit: the form. Its script (mods-submit.js) signs in, then sends the page and the file to the service, which -// checks the file and opens the pull request the reviewers read. -const field = (name, label, input, hint = "") => - ``; -const text = (name, attrs = "") => ``; - -export function submitHtml() { - return `

    ModsSubmit

    -

    Submit to the registry

    -

    A mod, a modpack of registry mods, or a resource pack made for Horizon. The form checks the file and opens a pull request; a reviewer reads it, its source and the checks' report, and merging publishes it to this site and the launcher.

    -

    First time? Publish a mod walks through building, checking and signing the file, and Make a mod starts from the beginning.

    -
    -

    The form needs JavaScript. You can also open a pull request by hand.

    -
    - `; -} - -// The service's "entry" from the form's values (strings), as it reads it. -export function entryFrom(v) { - const optional = (x) => (x && x.trim() ? x.trim() : undefined); - return { - id: v.id.trim(), - kind: v.kind, - name: v.name.trim(), - summary: v.summary.trim(), - description: v.description || "", - authors: (v.authors || "").split(",").map((a) => a.trim()).filter(Boolean), - license: v.license.trim(), - source: v.source.trim(), - icon: optional(v.icon), - key: optional(v.key), - signature: optional(v.signature), - version: v.version.trim(), - game: v.game.trim(), - changelog: v.changelog || "", - }; -} diff --git a/website/src/mods.test.mjs b/website/src/mods.test.mjs deleted file mode 100644 index c5ff2e783..000000000 --- a/website/src/mods.test.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { CAPABILITIES, entryFrom, listHtml, matches, projectHtml, readIndex, submitHtml } from "./mods.js"; - -const project = (over = {}) => ({ - id: "coords_hud", kind: "mod", name: "Coordinates HUD", summary: "Where you are.", authors: ["Horizon"], - license: "MIT", source: "https://example.org/src", - versions: [{ version: "0.1.0", api: "0.1", side: "client", capabilities: ["game-read", "hud"], game: "26.3", - file: { name: "coords_hud-0.1.0.hmod", url: "https://github.com/ludovic111/horizon-mods/releases/download/x/coords_hud-0.1.0.hmod", size: 438, sha256: "e".repeat(64) } }], - ...over, -}); - -test("reads schema 1, keeps the kinds it knows and refuses a newer schema", () => { - const index = { schema: 1, projects: [project(), project({ id: "map", kind: "world" }), project({ id: "Bad id" })] }; - assert.deepEqual(readIndex(index).map((p) => p.id), ["coords_hud"]); - assert.equal(readIndex({ schema: 2, projects: [project()] }), null); - assert.equal(readIndex(null), null); -}); - -test("filters by kind and every word", () => { - const p = project(); - assert.ok(matches(p, "all", "coordinates horizon")); - assert.ok(matches(p, "mod", "")); - assert.ok(!matches(p, "modpack", "")); - assert.ok(!matches(p, "all", "zoom")); -}); - -test("escapes what authors wrote and only links https addresses", () => { - const evil = project({ name: "", source: "javascript:alert(1)", summary: '">' }); - const html = listHtml([evil]) + projectHtml(evil); - assert.ok(!html.includes(" - - -
    -
    -

    PrivacyData collection

    -

    Nothing leaves without your yes.

    -

    The Horizon launcher can send a few things that help make the game faster and steadier. It asks first, each kind has its own switch, and every switch is off until you turn it on.

    -
    -

    What can be sent

    -

    Only the kinds you allow, from the launcher's first-run question or Settings → Data collection:

    -
      -
    • Performance statistics, after each play session of a minute or more. These are the frame-time and tick-time percentiles and a 14-bucket histogram (never a recording of each frame), the peak memory, how long the game took to start and to load the world, and the settings that change performance (render and simulation distance, frame limit, VSync, fullscreen, graphics mode).
    • -
    • Crash reports, when the game crashes. They hold the error, where it happened in Horizon's code, your graphics adapter and settings. They hold no log, no world, no server and no resource packs.
    • -
    • Bug reports that you write and send yourself. The end of the game's log goes with one only if you add it after reading it, and lines of chat are removed first.
    • -
    • Benchmark uploads of the benchmarks you run. They are shown publicly on the Benchmarks page, only as medians of groups of at least 3 different installs with the same graphics card, processor and render distance; never one by one, and never with your install ID, the day, your OS version, driver, exact memory or screen.
    • -
    -

    Each one also carries the Horizon version, the day it was made (not the time), and a description of your machine, because that is what explains how fast the game runs on it:

    -
      -
    • the operating system, its version and the processor architecture;
    • -
    • the processor model, its cores and threads, and its base and boost clock where the system gives them;
    • -
    • the memory, rounded to the gigabyte;
    • -
    • the graphics card the game draws with: its model, maker, whether it is built into the processor or separate, its own memory, its driver version and the graphics interface (Vulkan, Metal, DirectX 12);
    • -
    • the kind of drive the game folder is on (NVMe, SSD or hard disk);
    • -
    • the screen the game opened on: its size in pixels, refresh rate and scaling;
    • -
    • whether the computer is a laptop or a desktop, and whether it runs on battery.
    • -
    -

    No serial number, computer name, user name, network address or file path is read. Each item also carries a random install ID. The launcher makes that ID the first time you allow something; it says nothing about you and exists only so that your data can be deleted.

    -

    Never sent: your Microsoft or Minecraft account name or UUID, world names, server or LAN addresses, chat, and your user name or home folder in file paths. Before anything is kept, those are replaced with placeholders, along with e-mail and IP addresses. The server does not store the IP address requests come from.

    -

    The launcher shows exactly what would be sent: Settings → Data collection → View what would be sent, or horizon-launcher data show. Every field is listed in docs/telemetry.md.

    - -

    The "I'm playing" ping

    -

    The game sends an anonymous "I'm playing" ping, so the launcher can show how many people play Horizon right now. It is sent once a minute while you play, whatever the switches say.

    -

    It holds: a random number made when the game starts and forgotten when it closes, the Horizon version, your operating system (Linux, macOS or Windows), and whether you have a world open to LAN or to friends. It does not hold: your account, your install ID, world or server names, your hardware or settings, or anything you do in the game. The random number changes every time you start the game, so two of your sessions can't be linked to each other or to you.

    -

    The server keeps the pings in memory only, never on disk, and not with the address they came from (that is used only to stop abuse, as below). A session counts while it pinged in the last two minutes and is then forgotten. What it shows is the number playing now, per version and with a world open, rounded.

    - -

    Why

    -

    To find what is slow or crashes on which machines, and fix it. The ping shows players, and Horizon, how many people play right now. Benchmark uploads also show other players how Horizon runs on hardware like theirs. Nothing is sold, shared for advertising, or used to identify you.

    - -

    On what basis

    -

    For the switches: your consent (Article 6(1)(a) of the EU's General Data Protection Regulation). It is given kind by kind, recorded on your computer with the version of the question you answered and when, and withdrawn at any time with the same switches. Withdrawing deletes what was waiting to be sent. When what is collected changes, the launcher asks again, and nothing new is sent until you answer. The ping holds nothing that identifies you; the address it comes from is used only to limit how often one address may send, kept in memory for an hour at most and never stored.

    - -

    Where and how long

    -

    Items wait on your computer (telemetry/ in the game folder) and are sent compressed when the launcher starts or closes, to Horizon's own server, telemetry.playhorizon.dev, hosted by Railway in the European Union (Amsterdam, the Netherlands). The server keeps them for 180 days, then deletes them. What stays after that are anonymous totals per day, Horizon version, operating system, GPU maker and render distance (for example "120 sessions on macOS with Apple GPUs at render distance 12"). They hold no install ID, and groups too small to be anonymous are never shown. Published results also show how players split by memory size (in steps: 8, 16, 32 GB…), graphics memory, drive type, operating system and built-in or separate graphics, each class only once it holds at least 3 different installs; the exact values, the driver, the clocks and the screen are never published.

    - -

    Deleting your data

    -

    Settings → Data collection → Delete my data, or horizon-launcher data delete. The server deletes everything sent under your install ID, your share of the totals is recomputed, anything waiting on your computer is removed, and you get a new install ID. You can also turn everything off, or delete the telemetry/ folder.

    - -

    Your rights and contact

    -

    Horizon is made by Ludovic Marie, who is responsible for this data. To ask for a copy of what was sent under your install ID, have it corrected or deleted, or ask anything about it, write through lu4ovic.com (give your install ID, shown in Settings → Data collection). You can also complain to the data protection authority where you live.

    -

    This website itself sets no cookies and has no analytics; its footer says what it loads from GitHub.

    -
    - -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    - - - diff --git a/website/src/releases-page.js b/website/src/releases-page.js deleted file mode 100644 index ace84f63d..000000000 --- a/website/src/releases-page.js +++ /dev/null @@ -1,78 +0,0 @@ -// The Releases page: the list baked at build time, refreshed from GitHub when a newer release is out, and the -// channel filter. Without JavaScript the baked list reads as it is. -import { releasesPageHtml } from "./releases.js"; -import { isGame, isLauncher, newestFirst } from "./versions.js"; -import { cached, get, setUpMenu, setUpTheme, slim } from "./chrome.js"; - -const REPO = "ludovic111/horizon"; -const list = document.getElementById("release-list"); -const counter = document.querySelector("[data-release-count]"); -const buttons = [...document.querySelectorAll("[data-filter]")]; -let filter = "all"; - -const ours = (r) => !r.draft && (isGame(r.tag_name) || isLauncher(r.tag_name)); - -// Every release, a page of 100 at a time (GitHub's most), up to 500. -async function loadAll() { - const all = []; - for (let page = 1; page <= 5; page++) { - const batch = await get(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`); - all.push(...batch.filter(ours).map(slim)); - if (batch.length < 100) break; - } - return all; -} - -function apply() { - const entries = [...list.querySelectorAll(".release-entry")]; - let shown = 0; - for (const el of entries) { - const on = filter === "all" || el.dataset.channel === filter; - el.hidden = !on; - if (on) shown++; - } - counter.textContent = `${shown} release${shown === 1 ? "" : "s"}`; - buttons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.filter === filter))); -} - -async function refresh() { - // One small request first: the full list only when GitHub has something newer than the page. - const newest = await cached("horizon:releases-newest", () => get(`https://api.github.com/repos/${REPO}/releases?per_page=5`)).catch(() => null); - if (!newest) return; - const top = newest.filter(ours).map((r) => r.tag_name).sort(newestFirst)[0]; - if (!top || top === list.dataset.newest) return; - const all = await cached("horizon:releases-all", loadAll).catch(() => null); - if (!all || !all.length) return; - const open = new Set([...list.querySelectorAll(".release-entry[open]")].map((el) => el.id)); - list.innerHTML = releasesPageHtml(all); - list.dataset.newest = top; - // Keep what the reader opened open. - open.forEach((id) => document.getElementById(id)?.setAttribute("open", "")); - apply(); -} - -// A link to one release (/releases#release-v0.2.1) opens it. -function openTarget() { - const el = location.hash && document.getElementById(location.hash.slice(1)); - if (el && el.classList.contains("release-entry")) { - if (el.hidden) { - filter = "all"; - apply(); - } - el.open = true; - el.scrollIntoView(); - } -} - -buttons.forEach((b) => - b.addEventListener("click", () => { - filter = b.dataset.filter; - apply(); - }), -); -addEventListener("hashchange", openTarget); -setUpTheme(); -setUpMenu(); -apply(); -openTarget(); -refresh().then(openTarget); diff --git a/website/src/releases.html b/website/src/releases.html deleted file mode 100644 index 989164653..000000000 --- a/website/src/releases.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - Releases · Horizon - - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} -
    -
    -

    ReleasesEvery version, newest first

    -

    What's new in Horizon

    -

    Each release lists what it adds and fixes, then its full notes and its downloads. New versions come out many times a day as changes land, and the launcher installs each one once it has passed its test. The launcher has its own releases.

    -
    - - - -
    -

    {{RELEASE_COUNT}}

    -
    -{{RELEASE_LIST}} -
    -

    The releases on GitHub

    -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    -{{SITE_FOOTER}} - - diff --git a/website/src/releases.js b/website/src/releases.js deleted file mode 100644 index d4932e7f7..000000000 --- a/website/src/releases.js +++ /dev/null @@ -1,156 +0,0 @@ -// The release notes on the site: the home page's "Recently added" block and the Releases page (every release, -// newest first). Shared by build.mjs (baked into the pages) and the pages' scripts (refreshed from GitHub). -// Tags: the game's vX.Y.Z and the launcher's launcher@X.Y.Z from 0.2, vYYYY.MM.DD[.N] and launcher-vX.Y.Z before -// (src/versions.js). One channel (docs/release-flow.md, "One channel"): a release is the game's or the launcher's. -import { markdown, inline, escape } from "./markdown.js"; -import { isLauncher, launcherVersion, newestFirst } from "./versions.js"; - -const REPO = "ludovic111/horizon"; - -export const channel = (r) => (isLauncher(r.tag_name) ? "Launcher" : "Game"); -const anchor = (r) => `release-${r.tag_name.replace(/[^\w.-]/g, "-")}`; - -// "Horizon 0.2.3: Cave culling, weather" (before 0.2, "Horizon 2026.10.13: …") reads as the version and its line. -export function parts(r) { - const version = launcherVersion(r.tag_name) ?? r.tag_name.replace(/^v/, ""); - const name = (r.name || "").trim(); - let line = name.includes(": ") ? name.slice(name.indexOf(": ") + 2) : name.replace(/^Horizon( launcher)?\s*/i, "").replace(version, "").trim(); - if (junk(line)) line = fromNotes(r.body) || line; - return { version, line: line.charAt(0).toUpperCase() + line.slice(1), date: (r.published_at || "").slice(0, 10) }; -} - -// A title made of issue numbers and "fix" ("#1255, mob pushes, fix, #1222") says nothing. -const junk = (line) => { - const bits = line.split(",").map((b) => b.trim()).filter(Boolean); - return bits.length > 1 && bits.filter((b) => /^(#\d+|fix(es)?|fixed)$/i.test(b)).length * 2 >= bits.length; -}; - -// The first item that isn't a bare "Fix #123:" prefix, as the line: "Mob pushes: one list per mob… and 3 more fixes". -function fromNotes(body) { - const s = sections(body); - const items = [...s.filter((x) => /^new|what's new/i.test(x.title)), ...s.filter((x) => /^fixed/i.test(x.title))].flatMap((x) => x.items).map(plainItem); - const clean = items.map((i) => i.replace(/^(fix|fixes)\s+#\d+:\s*/i, "").replace(/^\w+\([^)]*\):\s*/, "")).filter(Boolean); - if (!clean.length) return ""; - const first = clean[0].replace(/[.]$/, ""); - return clean.length > 1 ? `${first} and ${clean.length - 1} more` : first; -} - -// The notes' "### Heading" sections and their top-level items, in order. -export function sections(body) { - const out = []; - let current = null; - for (const raw of (body || "").replace(/\r\n?/g, "\n").split("\n")) { - const heading = raw.match(/^#{2,3}\s+(.+?)\s*$/); - if (heading) out.push((current = { title: heading[1], items: [] })); - else if (current && /^[-*]\s+/.test(raw)) current.items.push(raw.replace(/^[-*]\s+/, "").trim()); - } - return out; -} - -// An item without its pull request references: "Riptide (#123, fixes #45)" -> "Riptide". -export const plainItem = (item) => item.replace(/\s*\((?:#\d+|fixes #\d+|[^()]*#\d+[^()]*)\)\s*/g, " ").replace(/\s+/g, " ").trim(); - -// A game release's notes open with an HTML block sending people to the launcher (PLAYERS in tools/release_train.py): -// GitHub's latest release is the game's. This site has its own download button, so it leaves the block out. -export const notesText = (body) => (body || "").replace(/^

    Want to play\?[^\n]*(?:\r?\n)*/, ""); - -// The notes' first sentence, for a release with no title line and no sections ("Rebuilt from the latest main."). -const firstLine = (body) => (notesText(body).split(/\r?\n/).find((l) => l.trim() && !/^#/.test(l)) || "").trim().replace(/[.:]$/, ""); - -const count = (n, one, many) => `${n} ${n === 1 ? one : many}`; - -// What a release changes, in a line: "12 new, 30 fixed, 2 known issues". -export function overview(r) { - const s = sections(r.body); - const by = (re) => s.filter((x) => re.test(x.title)).reduce((n, x) => n + x.items.length, 0); - const added = by(/^new|what's new/i); - const fixed = by(/^fixed/i); - const known = by(/^known/i); - const bits = []; - if (added) bits.push(count(added, "new feature", "new features")); - if (fixed) bits.push(count(fixed, "fix", "fixes")); - if (known) bits.push(count(known, "known issue", "known issues")); - return { added, fixed, known, text: bits.join(", ") }; -} - -export function highlights(r, n = 3) { - const added = sections(r.body).filter((x) => /^new|what's new/i.test(x.title)).flatMap((x) => x.items); - return added.slice(0, n).map(plainItem); -} - -const byTag = (a, b) => newestFirst(a.tag_name, b.tag_name); -const badge = (r) => `${channel(r)}`; - -// The home page: the newest releases on a line each, and what the newest ones added. -export function recentHtml(list, { rows = 5, picks = 3 } = {}) { - const sorted = [...list].sort(byTag); - const game = sorted.filter((r) => !isLauncher(r.tag_name)); - if (!game.length) return ""; - const lines = game - .slice(0, rows) - .map((r) => { - const p = parts(r); - return `
  • ${escape(p.version)}${escape(p.line || overview(r).text || firstLine(r.body) || "Release notes")}${badge(r)}
  • `; - }) - .join(""); - // What landed last: the "New" items of the newest releases with the highest pull request numbers (a release's - // notes list everything since the release before it, oldest first). - const pr = (item) => Math.max(0, ...[...item.matchAll(/#(\d+)/g)].map((m) => Number(m[1]))); - const seen = new Set(); - const picked = game - .slice(0, rows) - .flatMap((r) => sections(r.body).filter((x) => /^new|what's new/i.test(x.title)).flatMap((x) => x.items).map((raw) => ({ raw, item: plainItem(raw), r }))) - .filter(({ item }) => !seen.has(item) && seen.add(item)) - .sort((a, b) => pr(b.raw) - pr(a.raw)) - .slice(0, picks); - const picks_ = picked - .map(({ item, r }) => `
  • ${inline(item)}

    ${escape(parts(r).version)}
  • `) - .join(""); - return `
      ${lines}
    - ${picks_ ? `

    New lately

      ${picks_}
    ` : ""} -

    Every release, with its full notes

    `; -} - -const ASSET_LABELS = [ - [/linux-x86_64/, "Linux x86-64"], - [/macos-arm64/, "macOS, Apple Silicon"], - [/macos-x86_64/, "macOS, Intel"], - [/windows-x64/, "Windows x64"], -]; -function assetLabel(name) { - if (/^SHA256SUMS/.test(name)) return name.endsWith(".sig") ? "Checksums signature" : "Checksums"; - const os = ASSET_LABELS.find(([re]) => re.test(name)); - const what = /server/.test(name) ? "Server" : /launcher/.test(name) ? "Launcher" : /smoke-test/.test(name) ? "Smoke test" : "Game"; - return os ? `${what}, ${os[1]}` : name; -} -const size = (bytes) => (bytes >= 1e6 ? `${(bytes / 1e6).toFixed(0)} MB` : bytes >= 1e3 ? `${(bytes / 1e3).toFixed(0)} kB` : `${bytes} B`); - -function releaseHtml(r, open) { - const p = parts(r); - const o = overview(r); - const top = highlights(r, 4); - const downloads = (r.assets || []) - .map((a) => `
  • ${escape(assetLabel(a.name))}${escape(a.name)} · ${size(a.size || 0)}
  • `) - .join(""); - return `
    - ${escape(p.version)}${badge(r)}${escape(p.line)} -
    - ${o.text || top.length ? `
    - ${o.text ? `

    ${escape(o.text)}

    ` : ""} - ${top.length ? `
      ${top.map((t) => `
    • ${inline(t)}
    • `).join("")}
    ` : ""} -
    ` : ""} -
    ${markdown(notesText(r.body)) || "

    No notes for this release.

    "}
    - ${downloads ? `

    Downloads

      ${downloads}
    ` : ""} -

    This release on GitHub

    -
    -
    `; -} - -// The Releases page: every release, newest first; the newest few open. -export function releasesPageHtml(list, { open = 3 } = {}) { - const sorted = [...list].sort(byTag); - if (!sorted.length) return ""; - return sorted.map((r, i) => releaseHtml(r, i < open)).join("\n"); -} - -export const releasesUrl = `https://github.com/${REPO}/releases`; diff --git a/website/src/releases.test.mjs b/website/src/releases.test.mjs deleted file mode 100644 index acd1a0965..000000000 --- a/website/src/releases.test.mjs +++ /dev/null @@ -1,13 +0,0 @@ -// node --test src/: the release notes as the site shows them. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { notesText } from "./releases.js"; - -const players = '

    Want to play? Download the Horizon launcher, not the files below

    It installs this version.

    '; - -test("the block sending people to the launcher is left out, the notes stay", () => { - assert.equal(notesText(`${players}\n\n### Fixed\n- A fix (#1)\n`), "### Fixed\n- A fix (#1)\n"); - assert.equal(notesText(`${players}\r\n\r\nRebuilt from the latest main.\r\n`), "Rebuilt from the latest main.\r\n"); - assert.equal(notesText("

    Other HTML

    \n\n### New\n- A thing (#2)\n"), "

    Other HTML

    \n\n### New\n- A thing (#2)\n"); - assert.equal(notesText(undefined), ""); -}); diff --git a/website/src/style.css b/website/src/style.css deleted file mode 100644 index 1de2a3d3d..000000000 --- a/website/src/style.css +++ /dev/null @@ -1,918 +0,0 @@ -/* Horizon's design language (docs/design-system.md), set as a page. The tokens come from - crates/horizon-theme through src/tokens.css, which build.mjs puts before this file: the - launcher's Blocks palettes, type, spacing and motion. The base below lays out the page; the - "Blocks" section at the end gives it the launcher's blocky, Minecraft-like look. */ - -@font-face { font-family: "Geist"; src: url("/fonts/Geist-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; } -@font-face { font-family: "Geist"; src: url("/fonts/Geist-Medium.woff2") format("woff2"); font-weight: 500; font-display: swap; } -@font-face { font-family: "Geist"; src: url("/fonts/Geist-SemiBold.woff2") format("woff2"); font-weight: 600; font-display: swap; } -@font-face { font-family: "Geist Mono"; src: url("/fonts/GeistMono-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; } - -:root { - --g: clamp(var(--gutter-narrow), 4.5vw, var(--gutter)); - --max: 1440px; - --nav: 72px; -} - -*, *::before, *::after { box-sizing: border-box; } - -html { - background: var(--page); - color: var(--ink); - font: 400 var(--text-body) / var(--text-body-line) var(--font); - font-synthesis: none; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - scroll-behavior: smooth; - scroll-padding-top: calc(var(--nav) + 16px); - transition: background-color var(--move) var(--ease-move), color var(--move) var(--ease-move); -} -body { margin: 0; min-height: 100vh; overflow-x: clip; } -img { display: block; max-width: 100%; height: auto; } -h1, h2, h3 { margin: 0; font-weight: var(--text-heading-weight); } -p { margin: 0; } -ol, ul { margin: 0; padding: 0; list-style: none; } -::selection { background: var(--accent); color: var(--on-accent); } - -a { color: var(--accent); text-decoration: none; transition: color var(--quick) var(--ease-quick); } -a:hover { color: var(--ink); } -[hidden] { display: none !important; } -/* Links inside running text are underlined, so they don't rely on colour alone. */ -p a:not(.button):not(.link-arrow), li > p a, .prose a { - color: inherit; text-decoration: underline; text-decoration-color: var(--rule); - text-decoration-thickness: 1px; text-underline-offset: 3px; - transition: text-decoration-color var(--quick) var(--ease-quick); -} -p a:not(.button):not(.link-arrow):hover, li > p a:hover, .prose a:hover { text-decoration-color: currentColor; } -:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--radius); } - -code { - font: 0.86em/1.5 var(--mono); - background: var(--plate-soft); - border-radius: 4px; - padding: 1px 5px; - overflow-wrap: anywhere; -} - -.sr-only { - position: absolute; width: 1px; height: 1px; overflow: hidden; - clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; -} -.skip { - position: fixed; left: var(--g); top: 12px; z-index: 50; padding: 8px 14px; - background: var(--ink); color: var(--page); border-radius: var(--radius-pill); - transform: translateY(-200%); -} -.skip:focus { transform: none; } - -.container { width: 100%; max-width: var(--max); margin: 0 auto; padding: 0 var(--g); } - -/* --- Type ----------------------------------------------------------------------------- */ - -.display { - font-size: var(--text-display); line-height: var(--text-display-line); - letter-spacing: var(--text-display-tracking); font-weight: var(--text-display-weight); - text-wrap: balance; -} -.title { - font-size: var(--text-title); line-height: var(--text-title-line); - letter-spacing: var(--text-title-tracking); font-weight: var(--text-title-weight); - text-wrap: balance; -} -.lede { max-width: var(--measure); color: var(--muted); text-wrap: pretty; } -.kicker, .meta, .download-meta, .eyebrow, .row-index, .row-meta, .disclaimer { - font: var(--text-micro-weight) var(--text-micro) / var(--text-micro-line) var(--mono); - letter-spacing: var(--text-micro-tracking); - color: var(--muted); -} -.kicker { display: flex; gap: 16px; text-transform: uppercase; margin-bottom: 24px; } -.kicker span { color: var(--ink); } -.meta { margin-top: 16px; } -.download-meta { margin-top: 12px; } -.download-meta a { color: inherit; } - -/* --- Buttons --------------------------------------------------------------------------- */ - -.button { - display: inline-flex; align-items: center; justify-content: center; gap: 8px; - height: var(--control); padding: 0 16px; border: 0; border-radius: var(--radius-pill); - font: 500 var(--text-label) / 1 var(--font); cursor: pointer; white-space: nowrap; - transition: background-color var(--quick) var(--ease-quick), color var(--quick) var(--ease-quick), opacity var(--quick) var(--ease-quick); -} -.button.large { height: var(--control-large); padding: 0 22px; font-size: var(--text-ui); } -.button.small { height: 30px; padding: 0 14px; } -.button.primary { background: var(--accent); color: var(--on-accent); } -.button.primary:hover { color: var(--on-accent); opacity: 0.88; } -.button.secondary { background: var(--plate-soft); color: var(--ink); } -.button.secondary:hover { background: var(--plate); color: var(--ink); } -.button.ghost { background: transparent; color: var(--muted); } -.button.ghost:hover { background: var(--plate-soft); color: var(--ink); } -.button:active { opacity: 0.85; } - -.link-arrow { display: inline-flex; gap: 8px; color: var(--accent); font-weight: 500; } -.link-arrow span { transition: transform var(--quick) var(--ease-quick); } -.link-arrow:hover span { transform: translateX(3px); } - -/* --- Navigation ------------------------------------------------------------------------- */ - -.nav { - position: fixed; inset: 0 0 auto; z-index: 20; height: var(--nav); max-width: 100vw; - background: color-mix(in srgb, var(--page) 88%, transparent); - -webkit-backdrop-filter: blur(16px) saturate(1.2); backdrop-filter: blur(16px) saturate(1.2); - box-shadow: 0 1px 0 var(--rule); - transition: background-color var(--move) var(--ease-move), box-shadow var(--move) var(--ease-move); -} -.nav-inner { display: flex; align-items: center; gap: 24px; height: 100%; max-width: var(--max); margin: 0 auto; padding: 0 var(--g); } -.nav-mark { display: inline-flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 500; font-size: var(--text-label); } -.nav-mark:hover { color: var(--ink); } -.mark { width: 18px; height: 18px; } -.mark-stem { fill: var(--ink); } -.mark-low { fill: var(--ink); opacity: 0.36; } -.mark-line { fill: var(--accent); } -.nav-links { display: flex; gap: 2px; margin-left: auto; white-space: nowrap; } -.nav-links a, .nav-github, .theme-toggle { - display: inline-flex; align-items: center; height: 32px; padding: 0 12px; - border-radius: var(--radius-pill); color: var(--muted); font-size: var(--text-label); - transition: background-color var(--quick) var(--ease-quick), color var(--quick) var(--ease-quick); -} -.nav-links a:hover, .nav-github:hover, .theme-toggle:hover { color: var(--ink); background: var(--plate-soft); } -.nav-links a[aria-current] { color: var(--ink); background: var(--plate-soft); } -.nav-end { display: flex; align-items: center; gap: 2px; } -.theme-toggle { - border: 0; background: none; cursor: pointer; - font: var(--text-micro) / 1 var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; -} -.nav-end .button { margin-left: 8px; } -.menu-button { - display: none; align-items: center; gap: 10px; height: 44px; padding: 0 4px 0 14px; margin-left: 4px; - border: 0; background: none; color: var(--ink); font: 500 var(--text-label) / 1 var(--font); cursor: pointer; -} -.menu-icon { position: relative; width: 18px; height: 10px; } -.menu-icon::before, .menu-icon::after { - content: ""; position: absolute; left: 0; right: 0; height: 1.5px; border-radius: 1px; background: currentColor; - transition: transform var(--move) var(--ease-move), top var(--move) var(--ease-move); -} -.menu-icon::before { top: 0; } -.menu-icon::after { top: 8px; } -.is-menu .menu-icon::before { top: 4px; transform: rotate(45deg); } -.is-menu .menu-icon::after { top: 4px; transform: rotate(-45deg); } - -.menu { - position: fixed; inset: var(--nav) 0 0; z-index: 19; display: flex; flex-direction: column; max-width: 100vw; - justify-content: space-between; gap: 24px; overflow-y: auto; padding: 24px var(--g) 32px; background: var(--page); - opacity: 0; transition: opacity var(--move) var(--ease-move); -} -.menu.is-open { opacity: 1; } -.menu nav { display: flex; flex-direction: column; flex-shrink: 0; } -.menu nav a { - display: flex; align-items: baseline; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--rule); - color: var(--ink); font-size: var(--text-title); line-height: var(--text-title-line); - letter-spacing: var(--text-title-tracking); font-weight: 500; - opacity: 0; transform: translateY(var(--rise)); - transition: opacity var(--enter) var(--ease-enter), transform var(--enter) var(--ease-enter); -} -.menu.is-open nav a { opacity: 1; transform: none; } -.menu-foot { display: flex; flex-direction: column; gap: 16px; flex-shrink: 0; } -.menu-foot p { font-size: var(--text-caption); color: var(--muted); } - -@media (max-width: 1279px) { - .nav-links, .nav-github { display: none; } - .menu-button { display: inline-flex; } - .nav-end { margin-left: auto; } -} -@media (max-width: 520px) { - .nav-end .button { display: none; } -} - -/* --- Index lists: the signature component ------------------------------------------------ */ - -.index { border-top: 1px solid var(--rule); } -.row { position: relative; border-bottom: 1px solid var(--rule); } -.row > a, .row.card { - position: relative; display: grid; grid-template-columns: var(--index) 1fr auto; align-items: baseline; - column-gap: 16px; padding: 18px 0; color: var(--ink); -} -.row-index { transition: color var(--quick) var(--ease-quick); } -.row-name { font-size: var(--text-title); line-height: var(--text-title-line); letter-spacing: var(--text-title-tracking); font-weight: 500; } -.row-meta { text-align: right; } -/* Hover: a soft plate fades in behind the row and reaches past it; the index turns to ink. */ -.row > a::before, .row.card::before { - content: ""; position: absolute; inset: 0 calc(-1 * var(--plate-bleed)); z-index: -1; - border-radius: var(--radius-plate); background: var(--plate-soft); opacity: 0; - transition: opacity var(--quick) var(--ease-quick); -} -.row > a, .row.card { z-index: 0; } -.row > a:hover::before, .row.card:hover::before { opacity: 1; } -.row > a:hover .row-index, .row.card:hover .row-index { color: var(--ink); } -.row > a:hover, .row.card:hover { color: var(--ink); } - -/* --- Hero -------------------------------------------------------------------------------- */ - -.hero { padding: calc(var(--nav) + clamp(48px, 9vw, 120px)) 0 clamp(64px, 8vw, 112px); } -.hero-title { max-width: 14ch; margin-bottom: 20px; } -.hero .lede { font-size: var(--text-body); } -.hero-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 32px; } -.hero-index { margin-top: clamp(48px, 7vw, 88px); } -.hero .disclaimer { margin-top: 24px; } - -/* --- Sections ------------------------------------------------------------------------------- */ - -.section { padding: clamp(72px, 10vw, 144px) 0; } -.section-head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px 48px; align-items: end; margin-bottom: 48px; } -.section-head .kicker { grid-column: 1 / -1; margin-bottom: 0; } -.section .title { font-size: var(--text-display); line-height: var(--text-display-line); letter-spacing: var(--text-display-tracking); } - -.statement { - max-width: 28ch; font-size: var(--text-title); line-height: 1.15; - letter-spacing: var(--text-title-tracking); font-weight: 500; text-wrap: balance; -} -.facts { margin-top: 56px; } -.fact, .features li { - display: grid; grid-template-columns: var(--index) minmax(0, 18rem) minmax(0, 1fr); column-gap: 16px; - align-items: baseline; padding: 20px 0; -} -.fact h3, .features h3 { font-size: var(--text-heading); line-height: var(--text-heading-line); letter-spacing: var(--text-heading-tracking); } -.fact p, .features p { max-width: var(--measure); color: var(--muted); } - -/* Screenshots: no frames, the images themselves. */ -.mosaic { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; } -.tile { margin: 0; grid-column: span 2; } -.tile-wide { grid-column: span 4; } -.tile-tall { grid-column: span 2; } -.tile-wide, .tile-tall { grid-row: span 1; } -.zoom { display: block; width: 100%; padding: 0; border: 0; background: var(--plate-soft); border-radius: var(--radius-plate); overflow: hidden; cursor: zoom-in; } -.zoom img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; transition: transform var(--enter) var(--ease-enter); } -.zoom:hover img { transform: scale(1.02); } -figcaption { display: flex; gap: 12px; margin-top: 10px; font-size: var(--text-caption); color: var(--muted); } -figcaption span { font-family: var(--mono); font-size: var(--text-micro); letter-spacing: var(--text-micro-tracking); padding-top: 1px; } - -/* Parity: the count, then every subsystem as an index row. */ -.parity-hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; align-items: end; margin-bottom: 64px; } -.count { font-size: clamp(96px, 16vw, 220px); line-height: 0.85; letter-spacing: -0.05em; font-weight: 500; } -.count .digits span { display: inline-block; } -.count-label { margin-top: 20px; max-width: 32ch; color: var(--muted); } -.parity-copy .title { margin-bottom: 16px; } -.prose { color: var(--ink-soft); max-width: var(--measure); } -.prose > * + * { margin-top: 12px; } -.systems { border-top: 1px solid var(--rule); } -.system { - display: grid; grid-template-columns: var(--index) minmax(0, 1fr) auto; column-gap: 16px; row-gap: 6px; - padding: 20px 0; border-bottom: 1px solid var(--rule); position: relative; -} -.system-head { display: contents; } -.system-index { grid-column: 1; grid-row: 1; font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); padding-top: 0.6em; } -.system h3 { grid-column: 2; grid-row: 1; font-size: var(--text-heading); line-height: var(--text-heading-line); letter-spacing: var(--text-heading-tracking); } -.system-state { - grid-column: 3; grid-row: 1; display: inline-flex; align-items: center; gap: 6px; padding-top: 0.5em; - font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); - text-transform: uppercase; color: var(--muted); -} -.system.is-full .system-state { color: var(--success); } -.check { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.6; } -.system-detail { grid-column: 2 / 4; max-width: 72ch; font-size: var(--text-caption); line-height: 1.6; color: var(--muted); } -.gap { color: var(--ink); font-weight: 500; } -.legend { max-width: 80ch; } - -/* The launcher: copy and features on the left, the menus on the right. */ -.launcher-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(32px, 5vw, 80px); align-items: start; } -.launcher-copy .title { margin-bottom: 16px; } -.launcher-copy .features { margin-top: 40px; } -.features li { grid-template-columns: var(--index) minmax(0, 1fr); row-gap: 4px; } -.features li p { grid-column: 2; font-size: var(--text-ui); } -.launcher-actions { margin-top: 32px; } -.screens { position: sticky; top: calc(var(--nav) + 24px); } -.tabs { position: relative; display: inline-flex; padding: 2px; gap: 2px; margin-bottom: 16px; border-radius: var(--radius-pill); background: var(--plate-soft); } -.tabs button { - position: relative; z-index: 1; height: var(--segment); padding: 0 14px; border: 0; background: none; - border-radius: var(--radius-pill); color: var(--muted); font: 500 var(--text-label) / 1 var(--font); cursor: pointer; - transition: color var(--quick) var(--ease-quick); -} -.tabs button:hover { color: var(--ink); } -.tabs button[aria-selected="true"] { color: var(--page); } -.tab-pill { position: absolute; top: 0; left: 0; border-radius: var(--radius-pill); background: var(--ink); transition: transform var(--move) var(--ease-move), width var(--move) var(--ease-move); } -.screen { position: relative; aspect-ratio: 16 / 10; border-radius: var(--radius-plate); overflow: hidden; background: var(--plate-soft); } -.screen-panel { position: absolute; inset: 0; opacity: 0; transition: opacity var(--move) var(--ease-move); } -.screen-panel.is-active { opacity: 1; } -.screen-panel img { width: 100%; height: 100%; object-fit: cover; } -.screen-caption { margin-top: 12px; font-size: var(--text-caption); color: var(--muted); } - -/* Patch notes. */ -.eyebrow { text-transform: uppercase; } -.prose h2, .prose h3, .prose h4 { font-size: var(--text-heading); line-height: var(--text-heading-line); color: var(--ink); margin-top: 28px; } -.prose ul, .prose ol { padding-left: 18px; list-style: disc; } -.prose li + li { margin-top: 6px; } -.prose li::marker { color: var(--muted); } -.prose strong { color: var(--ink); font-weight: 500; } -.prose pre { padding: 16px; border-radius: var(--radius-plate); background: var(--plate-soft); overflow-x: auto; } -.prose pre code { background: none; padding: 0; } -.release { border-top: 1px solid var(--rule); } -.release:last-of-type { border-bottom: 1px solid var(--rule); } -.release summary { - display: grid; grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) auto; gap: 16px; align-items: baseline; - padding: 18px 0; cursor: pointer; list-style: none; -} -.release summary::-webkit-details-marker { display: none; } -.release-tag { font-size: var(--text-heading); font-weight: 500; letter-spacing: var(--text-heading-tracking); } -.release-sub { color: var(--muted); font-size: var(--text-ui); } -.release time { font: var(--text-micro) / 1 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.release[open] summary .release-tag { color: var(--accent); } -.release .prose { padding: 0 0 24px 12rem; margin-left: 16px; font-size: var(--text-ui); } -.release-more { margin-top: 24px; } - -/* Community. */ -.cards { margin-top: 48px; } -.row.card { grid-template-columns: var(--index) minmax(0, 1fr) minmax(0, 1.4fr) auto; } -.card-title { font-size: var(--text-title); line-height: var(--text-title-line); letter-spacing: var(--text-title-tracking); font-weight: 500; } -.card-text { color: var(--muted); } -.card-arrow { color: var(--muted); transition: transform var(--quick) var(--ease-quick), color var(--quick) var(--ease-quick); } -.card:hover .card-arrow { color: var(--accent); transform: translateX(4px); } -.contributors { margin-top: 96px; } -.contributors-title { font-size: var(--text-heading); line-height: var(--text-heading-line); } -.contributors-text { margin-top: 8px; color: var(--muted); } -.contributors-list { margin-top: 24px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0 24px; border-top: 1px solid var(--rule); } -.contributor { display: grid; grid-template-columns: 32px 1fr; column-gap: 12px; align-items: center; padding: 14px 0; border-bottom: 1px solid var(--rule); color: var(--ink); } -.contributor-avatar { grid-row: span 2; width: 32px; height: 32px; border-radius: 50%; background: var(--plate); } -.contributor-name { font-weight: 500; } -.contributor-count { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.contributor:hover .contributor-name { color: var(--accent); } - -/* --- Footer: small print, then the giant faint wordmark --------------------------------- */ - -.footer { padding: clamp(72px, 9vw, 120px) 0 0; overflow: hidden; } -.footer-disclaimer { max-width: 24ch; font-size: var(--text-heading); line-height: var(--text-heading-line); letter-spacing: var(--text-heading-tracking); font-weight: 500; } -.footer-cols { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; margin-top: 40px; } -.footer-cols p { font-size: var(--text-caption); line-height: 1.7; color: var(--muted); } -.footer-bar { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-top: 56px; font-size: var(--text-caption); color: var(--muted); } -.footer-links { display: flex; flex-wrap: wrap; gap: 20px; } -.footer-links a { color: var(--muted); } -.footer-links a:hover { color: var(--ink); } -.footer-word { - box-sizing: border-box; width: 100%; margin: 24px auto 0; padding: 0 var(--g); max-width: var(--max); - font-size: var(--text-wordmark); line-height: var(--text-wordmark-line); letter-spacing: var(--text-wordmark-tracking); - font-weight: var(--text-wordmark-weight); color: var(--plate); white-space: nowrap; user-select: none; - translate: -0.05em 0.12em; -} -:root[data-theme="dark"] .footer-word { color: var(--plate-soft); } -@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .footer-word { color: var(--plate-soft); } } - -/* --- Lightbox ---------------------------------------------------------------------------- */ - -.lightbox { width: min(1400px, 94vw); max-height: 92vh; padding: 0; border: 0; border-radius: var(--radius-plate); background: var(--surface); color: var(--ink); } -.lightbox::backdrop { background: color-mix(in srgb, var(--page) 86%, transparent); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } -.lightbox img { width: 100%; } -.lightbox form { position: absolute; top: 12px; right: 12px; } -.lightbox-caption { padding: 14px 16px; font-size: var(--text-caption); color: var(--muted); } - -/* --- 404 --------------------------------------------------------------------------------- */ - -.lost { min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; } -.lost .hero { padding-top: clamp(96px, 16vh, 200px); } - -/* --- Motion: content arrives with a fade and a short rise, a few tens of ms apart --------- */ - -.js .reveal, .js .reveal-media { - opacity: 0; transform: translateY(var(--rise)); filter: blur(4px); - transition: opacity var(--enter) var(--ease-enter) var(--d, 0ms), transform var(--enter) var(--ease-enter) var(--d, 0ms), filter var(--enter) var(--ease-enter) var(--d, 0ms); -} -.js .reveal.is-visible, .js .reveal-media.is-visible { opacity: 1; transform: none; filter: none; } -.js .systems .system { opacity: 0; transform: translateY(var(--rise)); transition: opacity var(--enter) var(--ease-enter) var(--d, 0ms), transform var(--enter) var(--ease-enter) var(--d, 0ms); } -.js .systems.is-visible .system { opacity: 1; transform: none; } -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - .js .reveal, .js .reveal-media, .js .systems .system { transform: none; filter: none; transition: opacity 200ms linear; } - .menu nav a { transform: none; } -} - -/* --- Touch: every target at least 44 px high ------------------------------------------- */ - -@media (pointer: coarse) { - .button, .button.large, .button.small, .tabs button { height: 44px; } - .nav-mark, .theme-toggle, .nav-github { min-height: 44px; } - .link-arrow, .footer-links a { display: inline-flex; align-items: center; min-height: 44px; } -} - -/* --- Narrow screens ------------------------------------------------------------------------ */ - -@media (max-width: 900px) { - .section-head, .parity-hero, .launcher-grid { grid-template-columns: minmax(0, 1fr); } - .section-head { gap: 16px; } - .screens { position: static; } - .fact { grid-template-columns: var(--index) minmax(0, 1fr); row-gap: 6px; } - .fact p { grid-column: 2; } - .mosaic { grid-template-columns: minmax(0, 1fr); } - .tile, .tile-wide, .tile-tall { grid-column: auto; } - .row.card { grid-template-columns: var(--index) minmax(0, 1fr) auto; row-gap: 4px; } - .card-text { grid-column: 2; grid-row: 2; font-size: var(--text-ui); } - .card-arrow { grid-row: 1; grid-column: 3; } - .release summary { grid-template-columns: minmax(0, 1fr) auto; } - .release-sub { grid-column: 1 / -1; grid-row: 2; } - .release .prose { padding-left: 0; margin-left: 0; } - .footer-cols { grid-template-columns: minmax(0, 1fr); } - .footer-bar { flex-direction: column; align-items: flex-start; } - .system { grid-template-columns: var(--index) minmax(0, 1fr); } - .system-state { grid-column: 2; grid-row: 2; padding-top: 0; } - .system-detail { grid-column: 2; } - .row > a { grid-template-columns: 28px 1fr auto; padding: 16px 0; } - :root { --index: 28px; } -} - -/* The privacy page: one long read under the hero type. */ -.privacy h1 { margin-bottom: 16px; } -.privacy .prose { margin: 40px 0; } -.privacy .disclaimer { margin-top: 32px; } - -/* --- Documentation (/docs): a left navigation and one page at a time --------------------- */ - -.docs { - display: grid; grid-template-columns: 248px minmax(0, 1fr); gap: clamp(32px, 6vw, 96px); - max-width: var(--max); margin: 0 auto; padding: calc(var(--nav) + 48px) var(--g) 0; align-items: start; -} -.docs-nav-wrap { position: sticky; top: calc(var(--nav) + 24px); max-height: calc(100vh - var(--nav) - 48px); overflow-y: auto; padding-bottom: 24px; } -.docs-nav-wrap summary { display: none; } -.docs-nav-group { margin: 28px 0 8px; font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); } -.docs-nav-group:first-child { margin-top: 0; } -.docs-nav li a { display: block; padding: 6px 12px; margin-left: -12px; border-radius: var(--radius); color: var(--ink-soft); font-size: var(--text-ui); } -.docs-nav li a:hover { background: var(--plate-soft); color: var(--ink); } -.docs-nav li a[aria-current] { background: var(--plate-soft); color: var(--ink); font-weight: 500; } -.docs-main { min-width: 0; padding-bottom: 48px; } -.docs-main .title { margin-bottom: 16px; } -.docs-main .lede { max-width: 64ch; font-size: var(--text-heading); line-height: 1.45; } -.docs-toc { margin-top: 28px; max-width: 72ch; } -.docs-toc p { font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); margin-bottom: 8px; } -.docs-toc ul { display: flex; flex-wrap: wrap; gap: 6px; } -.docs-toc a { display: block; padding: 4px 12px; border-radius: var(--radius-pill); background: var(--plate-soft); color: var(--ink-soft); font-size: var(--text-label); } -.docs-toc a:hover { background: var(--plate); color: var(--ink); } -.docs-prose { max-width: 72ch; margin-top: 40px; font-size: var(--text-body); } -.docs-prose > * + * { margin-top: 14px; } -.docs-prose h2 { margin-top: 56px; padding-top: 24px; border-top: 1px solid var(--rule); font-size: clamp(24px, 2.4vw, 30px); line-height: 1.2; letter-spacing: var(--text-title-tracking); scroll-margin-top: 80px; } -.docs-prose h2:first-child { margin-top: 0; padding-top: 0; border-top: 0; } -.docs-prose h3 { margin-top: 32px; scroll-margin-top: 80px; } -.docs-prose ol { list-style: decimal; } -.docs-prose pre { font: 0.86em/1.6 var(--mono); } -.docs-prose blockquote { padding: 2px 0 2px 16px; border-left: 2px solid var(--accent); color: var(--ink-soft); } -.docs-prose blockquote > * + * { margin-top: 8px; } -.docs-prose figure { margin: 28px 0; } -.docs-prose figure img { width: 100%; border-radius: var(--radius-plate); box-shadow: 0 0 0 1px var(--rule); } -.docs-prose figcaption { margin-top: 10px; font-size: var(--text-caption); color: var(--muted); } -.docs-pager { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; max-width: 72ch; margin-top: 56px; } -.docs-pager a { display: flex; flex-direction: column; gap: 2px; padding: 14px 16px; border-radius: var(--radius-plate); box-shadow: 0 0 0 1px var(--rule); color: var(--ink); font-weight: 500; } -.docs-pager a:hover { background: var(--plate-soft); color: var(--ink); } -.docs-pager a span { font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); font-weight: 400; } -.docs-pager-next { grid-column: 2; text-align: right; } -.docs-edit { margin-top: 32px; font-size: var(--text-caption); color: var(--muted); } -.docs-edit a { color: inherit; text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; } -.docs-main .disclaimer { margin-top: 24px; } - -@media (max-width: 900px) { - .docs { grid-template-columns: minmax(0, 1fr); gap: 0; padding-top: calc(var(--nav) + 12px); } - .docs-nav-wrap { position: static; max-height: none; overflow: visible; margin-bottom: 24px; border-bottom: 1px solid var(--rule); } - .docs-nav-wrap summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; font-weight: 500; list-style: none; } - .docs-nav-wrap summary::-webkit-details-marker { display: none; } - .docs-nav-wrap summary::after { content: "+"; margin-left: auto; color: var(--muted); } - .docs-nav-wrap[open] summary::after { content: "\2212"; } - .docs-pager { grid-template-columns: minmax(0, 1fr); } - .docs-pager-next { grid-column: 1; } -} -@media (pointer: coarse) { - .docs-nav li a, .docs-toc a { display: flex; align-items: center; min-height: 44px; } -} - -/* --- Blocks: the launcher's look on the page (docs/design-system.md, "Blocks") ------------- - Pixel type for headings and controls, square panels with a dark outline, a light bevel on top - and a darker lip below, the violet face for the main action, sunk wells, a stone texture - behind the page and Horizon's pixel landscape behind the hero. The art is drawn in code - (scripts/art.mjs, like launcher/src/art.rs); nothing in it comes from Minecraft. */ - -@font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-Medium.woff2") format("woff2"); font-weight: 500; font-display: swap; } -@font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-SemiBold.woff2") format("woff2"); font-weight: 600; font-display: swap; } -@font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; } - -:root { - --stone: url("/media/stone-light.png"); - --scene: url("/media/scene-light.png"); - --sky: #8d7fd8; - --panel-edges: inset 0 var(--block-bevel) 0 var(--bevel), inset 0 calc(-1 * var(--block-bevel)) 0 var(--lip); - --radius: 0px; --radius-plate: 0px; --radius-pill: 0px; -} -:root[data-theme="dark"] { --stone: url("/media/stone-dark.png"); --scene: url("/media/scene-dark.png"); --sky: #0a0f2c; } -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { --stone: url("/media/stone-dark.png"); --scene: url("/media/scene-dark.png"); --sky: #0a0f2c; } -} - -/* The stone: 64 texels of the page colour in close shades, each texel 4 px. */ -body { position: relative; isolation: isolate; } -body::before { - content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; - background: var(--stone) 0 0 / 256px 256px repeat; image-rendering: pixelated; -} - -/* Pixel type for what names and what you act on; paragraphs stay in Geist. */ -.display, .title, .statement, .row-name, .card-title, .release-tag, .count, -.fact h3, .features h3, .system h3, .contributors-title, .footer-disclaimer, .footer-word, -.nav-mark, .docs-prose h2, .docs-prose h3, .prose h2, .prose h3, .prose h4, .menu nav a { - font-family: var(--pixel); font-weight: 600; letter-spacing: 0; -} -.display, .count, .footer-word { font-weight: 700; } -.nav-mark { font-size: 18px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; } -code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--outline); } - -/* Buttons: blocks with an outline, a bevel and a lip; they sink a pixel when pressed. */ -.button { - border: var(--block-outline) solid var(--outline); border-radius: 0; - font: 500 var(--text-ui) / 1 var(--pixel); - box-shadow: inset 0 var(--block-bevel) 0 var(--button-bevel), inset 0 calc(-1 * var(--block-lip)) 0 var(--lip); - padding-bottom: 2px; -} -.button.large { font-size: 18px; } -.button.primary { background: var(--face); color: var(--on-face); box-shadow: inset 0 var(--block-bevel) 0 var(--face-bevel), inset 0 calc(-1 * var(--block-lip)) 0 var(--face-lip); } -.button.primary:hover { opacity: 1; filter: brightness(1.08); } -.button.secondary { background: var(--button); } -.button.secondary:hover { background: var(--button); filter: brightness(1.06); } -.button.ghost { border-color: transparent; box-shadow: none; } -.button:active { opacity: 1; transform: translateY(1px); box-shadow: inset 0 var(--block-bevel) 0 var(--lip); } -:focus-visible { border-radius: 0; } - -/* The top bar: the sidebar colour with an outline under it, tabs with an accent underline. */ -.nav { background: var(--side); -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: 0 var(--block-outline) 0 var(--outline); } -.nav-links a, .nav-github, .theme-toggle { border-radius: 0; font-family: var(--pixel); font-weight: 500; font-size: 15px; } -.nav-links a:hover, .nav-github:hover, .theme-toggle:hover { background: var(--raised); } -.nav-links a[aria-current] { background: var(--raised); box-shadow: inset 0 -3px 0 var(--accent); } -.theme-toggle { text-transform: none; letter-spacing: 0; } -.menu { background: var(--side); } -.menu nav a { border-bottom: 0; } - -/* Lists become stacks of panels. */ -.index, .systems, .contributors-list { border-top: 0; display: grid; gap: 8px; } -.row, .system, .release, .release:last-of-type, .contributor { border-bottom: 0; border-top: 0; } -.row > a, .row.card, .fact.row, .features li, .system, .release, .contributor, .docs-pager a { - background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); - border-radius: 0; -} -.row > a, .row.card, .fact.row, .features li, .system { padding-left: 20px; padding-right: 20px; } -.row > a::before, .row.card::before { display: none; } -.row > a:hover, .row.card:hover, .contributor:hover, .docs-pager a:hover { background: var(--raised); } -.row > a:hover .row-index { color: var(--accent); } -.row-name { font-size: clamp(20px, 2.2vw, 28px); } -.release { padding: 0 20px; } -.release + .release { margin-top: 8px; } -.release .prose { padding-left: 12rem; } -.contributor { padding: 12px 14px; } -.contributors-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; } -.contributor-avatar { border-radius: 0; box-shadow: 0 0 0 2px var(--outline); image-rendering: pixelated; } -.system.is-full .system-state { color: var(--success); } - -/* Screenshots and the launcher's screens: square, outlined. */ -.zoom, .screen, .lightbox, .prose pre, .docs-prose figure img { border-radius: 0; box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.tabs { border-radius: 0; background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.tabs button { border-radius: 0; font-family: var(--pixel); font-weight: 500; font-size: 15px; } -.tabs button[aria-selected="true"] { color: var(--on-face); } -.tab-pill { border-radius: 0; background: var(--face); box-shadow: inset 0 var(--block-bevel) 0 var(--face-bevel), inset 0 calc(-1 * var(--block-bevel)) 0 var(--face-lip); } - -/* The hero: Horizon's landscape under a veil of the page colour, the words on the left. */ -.hero { - position: relative; isolation: isolate; overflow: hidden; - background: var(--scene) 70% 100% / clamp(1200px, 135vw, 2400px) auto no-repeat, var(--sky); image-rendering: pixelated; - border-bottom: var(--block-outline) solid var(--outline); -} -.hero::before { - content: ""; position: absolute; inset: 0; z-index: -1; - background: - linear-gradient(90deg, color-mix(in srgb, var(--page) 92%, transparent) 0%, color-mix(in srgb, var(--page) 78%, transparent) 45%, color-mix(in srgb, var(--page) 0%, transparent) 78%), - linear-gradient(180deg, color-mix(in srgb, var(--page) 0%, transparent) 55%, color-mix(in srgb, var(--page) 70%, transparent) 100%); -} -.hero .kicker span, .hero .kicker { color: var(--ink-soft); } -.hero .lede { color: var(--ink-soft); } -.hero { padding-bottom: clamp(160px, 18vw, 280px); } -.hero-after { padding-top: 40px; } -.hero-after .hero-index { margin-top: 0; max-width: 860px; } -.hero-after .disclaimer { margin-top: 24px; } -@media (max-width: 900px) { - .hero::before { background: color-mix(in srgb, var(--page) 84%, transparent); } -} - -/* Documentation. */ -.docs-nav li a { border-radius: 0; } -.docs-nav li a[aria-current] { background: var(--raised); box-shadow: inset 4px 0 0 var(--accent); } -.docs-toc a { border-radius: 0; background: var(--button); box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.docs-pager a { box-shadow: var(--panel-edges); } -.docs-prose h2 { border-top: var(--block-outline) solid var(--outline); } -.docs-prose blockquote { border-left: 4px solid var(--accent); background: var(--panel); padding: 10px 16px; box-shadow: 0 0 0 var(--block-outline) var(--outline); } - -/* The footer: the giant wordmark in pixels. */ -.footer { background: var(--side); border-top: var(--block-outline) solid var(--outline); margin-top: 48px; } -.footer-word { color: var(--raised); letter-spacing: 0.02em; } -:root[data-theme="dark"] .footer-word { color: var(--panel); } -@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .footer-word { color: var(--panel); } } - -/* --- Releases and Benchmarks pages, and the home page's "Recently added" ------------------ */ - -/* Pixelify Sans' "fi" ligature draws as "A" ("8 Axes"): no common ligatures anywhere. */ -:root { font-variant-ligatures: no-common-ligatures; } - -.page-main { padding-top: calc(var(--nav) + clamp(40px, 6vw, 80px)); } -.page-title { max-width: 18ch; margin-bottom: 20px; } -.page-main > .container > .lede { font-size: var(--text-body); } -.page-main .disclaimer { margin-top: 48px; } - -/* Release and source badges: small square tags. */ -.badge { - display: inline-flex; align-items: center; height: 22px; padding: 0 8px; margin-right: 10px; vertical-align: 2px; - font: 500 12px / 1 var(--pixel); letter-spacing: 0.02em; text-transform: uppercase; white-space: nowrap; - border: var(--block-outline) solid var(--outline); background: var(--well); color: var(--ink-soft); -} -.badge-game { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); } -.badge-launcher { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); color: var(--ink); } -.badge-us { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); color: var(--ink); } -.badge-players { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); } -.badge-estimate { background: color-mix(in srgb, var(--warn) 24%, var(--panel)); color: var(--ink); } - -/* Home: the newest releases on a line each, then what they added. */ -.recent-list .row > a { grid-template-columns: 8.5rem minmax(0, 13rem) minmax(0, 1fr) auto; align-items: baseline; } -.recent-line { color: var(--muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.recent-list .row-name { font-size: clamp(18px, 1.8vw, 22px); } -.recent-list .badge { margin-right: 0; } -.recent-picks { margin-top: 32px; } -.recent-picks .eyebrow { margin-bottom: 12px; } -.highlights { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } -.highlight { - display: flex; flex-direction: column; justify-content: space-between; gap: 16px; padding: 18px 20px; - background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); -} -.highlight p { color: var(--ink); } -.highlight-from { font: var(--text-micro) / 1 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.highlight-from:hover { color: var(--accent); } - -/* Releases: a filter, then every release as a panel that opens. */ -.release-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 32px; } -.release-filter .button[aria-pressed="true"], .hub-detail .button[aria-pressed="true"], .button.hub-vote[aria-pressed="true"] { background: var(--face); color: var(--on-face); box-shadow: inset 0 var(--block-bevel) 0 var(--face-bevel), inset 0 calc(-1 * var(--block-lip)) 0 var(--face-lip); } -.release-count { margin: 16px 0 12px; } -.release-list { display: grid; gap: 8px; } -.release-list .release + .release { margin-top: 0; } -.release-entry summary { grid-template-columns: minmax(0, 11rem) minmax(0, 1fr) auto; } -.release-entry .release-sub { display: flex; align-items: baseline; flex-wrap: wrap; row-gap: 4px; } -.release-detail { padding: 0 0 24px 11rem; margin-left: 16px; display: grid; gap: 20px; max-width: calc(11rem + 16px + var(--measure) + 120px); } -.release-detail .prose { padding: 0; margin: 0; font-size: var(--text-ui); max-width: none; } -.release-overview { padding: 16px 18px; background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.release-counts { font-family: var(--pixel); font-weight: 600; color: var(--ink); margin-bottom: 8px; } -.release-overview ul { list-style: square; padding-left: 18px; color: var(--ink-soft); font-size: var(--text-ui); } -.release-overview li + li { margin-top: 4px; } -.release-downloads ul { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; margin-top: 8px; } -.release-downloads li { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; background: var(--button); box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.release-downloads li a { font-weight: 500; } -.release-downloads li span { font: var(--text-micro) / 1.4 var(--mono); color: var(--muted); overflow-wrap: anywhere; } -.release-github { font-size: var(--text-caption); } -.release-entry:target { box-shadow: var(--panel-edges), 0 0 0 3px var(--accent); } - -/* Benchmarks. */ -.bench-section { margin-top: clamp(64px, 8vw, 112px); } -.bench-section > .title { margin-bottom: 16px; } -.bench-section > .lede { margin-bottom: 32px; } -.machines { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; } -.machine { min-width: 0; padding: 24px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); } -.machine-head { display: grid; gap: 10px; margin-bottom: 20px; } -.machine-name { font-family: var(--pixel); font-weight: 600; font-size: var(--text-title); line-height: var(--text-title-line); } -.machine-facts { display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 0; } -.machine-facts div { display: flex; gap: 8px; min-width: 0; } -.machine-facts dt { font: var(--text-micro) / 1.6 var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); } -.machine-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; font-size: var(--text-caption); color: var(--ink-soft); } -.machine-note { font-size: var(--text-caption); color: var(--muted); max-width: 90ch; } -/* relative: the screen-reader-only labels inside are absolute and would escape the scroll box otherwise. */ -.table-wrap { position: relative; overflow-x: auto; } -.bench-table { width: 100%; border-collapse: collapse; font-size: var(--text-ui); } -.bench-table th, .bench-table td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--rule); vertical-align: baseline; } -.bench-table thead th { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); font-weight: 400; } -.bench-table thead tr:first-child th { color: var(--ink); } -.bench-table tbody th { font-weight: 500; color: var(--ink); } -.bench-table td.num { font-variant-numeric: tabular-nums; white-space: nowrap; } -.bench-table td.wins { color: var(--ink); font-weight: 600; } -.bench-table td.vs { font-size: var(--text-caption); white-space: nowrap; } -.md-table { width: 100%; border-collapse: collapse; font-size: var(--text-ui); } -.md-table th, .md-table td { padding: 8px 12px 8px 0; text-align: left; border-bottom: 1px solid var(--rule); vertical-align: baseline; } -.md-table th { font-weight: 500; color: var(--ink); } -.md-table td code, .md-table th code { overflow-wrap: anywhere; } -.vs-horizon { color: var(--success); } -.vs-java { color: var(--danger); } -.vs-tie, .vs-none { color: var(--muted); } -.capped { font: var(--text-micro) / 1 var(--mono); color: var(--warn); cursor: help; } -.metric-hint { display: block; font: var(--text-micro) / 1.4 var(--mono); color: var(--muted); font-weight: 400; } -.java-wins { margin-top: 16px; font-size: var(--text-caption); color: var(--ink-soft); } -.picker { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: end; padding: 20px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); } -.picker label { display: grid; gap: 6px; } -.picker label > span, .picker legend { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); } -.picker select { - min-width: min(320px, 80vw); height: var(--control); padding: 0 10px; font: inherit; font-size: var(--text-ui); color: var(--ink); - background: var(--well); border: var(--block-outline) solid var(--outline); border-radius: 0; -} -.picker-rd { display: flex; gap: 16px; align-items: center; border: 0; padding: 0; margin: 0; } -.picker-rd legend { margin-bottom: 6px; } -.picker-rd label { display: inline-flex; align-items: center; gap: 6px; font-family: var(--pixel); font-weight: 500; height: var(--control); } -.picker-rd input { accent-color: var(--accent); width: 18px; height: 18px; } -.pick-result { margin-top: 16px; padding: 20px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); } -.pick-head { margin-bottom: 12px; font-size: var(--text-caption); color: var(--ink-soft); line-height: 1.8; } -.pick-result .meta { margin-top: 12px; } -.pick-notes { margin: 12px 0 0; padding-left: 20px; font-size: var(--text-caption); color: var(--ink-soft); line-height: 1.6; } -.players-hardware th[scope="rowgroup"] { vertical-align: top; } -.empty { color: var(--muted); } -.prose-section .prose { max-width: 80ch; } -.spec-table { margin-top: 24px; } -.spec-table summary { cursor: pointer; font-family: var(--pixel); font-weight: 600; } -.spec-table .bench-table { margin-top: 16px; max-width: 760px; } - -@media (max-width: 900px) { - .recent-list .row > a { grid-template-columns: minmax(0, 1fr) auto; row-gap: 4px; } - .recent-list .row-index { grid-column: 1; grid-row: 1; } - .recent-list .row-name { grid-column: 1; grid-row: 2; } - .recent-line { grid-column: 1 / -1; grid-row: 3; white-space: normal; } - .recent-list .row-meta { grid-column: 2; grid-row: 1; } - .highlights { grid-template-columns: minmax(0, 1fr); } - .release-entry summary { grid-template-columns: minmax(0, 1fr) auto; } - .release-detail { padding-left: 0; margin-left: 0; } - .machine { padding: 16px; } -} - -/* --- The hub (/hub): tabs, the Simple / Technical switch, roadmap items as panels ------------------- */ - -.hub-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; margin-top: 32px; } -.hub-tabs { margin-bottom: 0; } -.hub-detail { display: flex; gap: 8px; } -/* A link to a tab (/hub#roadmap) lands with the tabs in view under the top bar. */ -.hub-panel { margin-top: 40px; scroll-margin-top: calc(var(--nav) + 120px); } -.js .hub-panel:not(.is-active) { display: none; } -/* Simple shows `plain`, Technical shows `dev`; without the switch (no JavaScript) Simple reads. */ -.hub .hub-dev { display: none; } -:root[data-detail="dev"] .hub .hub-dev { display: block; } -:root[data-detail="dev"] .hub .hub-plain { display: none; } -.hub-lead { max-width: 60ch; font-size: var(--text-heading); line-height: var(--text-heading-line); color: var(--ink); } -.hub-block { margin-top: 56px; } -.hub-block > .title { margin-bottom: 16px; } -.hub-intro { margin-bottom: 16px; color: var(--ink-soft); max-width: var(--measure); } -.hub-rows .row > a { grid-template-columns: var(--index) minmax(0, 1fr) auto; align-items: baseline; } -.hub-row-body { display: grid; gap: 4px; min-width: 0; } -.hub-row-title { font-size: clamp(18px, 1.8vw, 22px); font-weight: 500; color: var(--ink); } -.hub-row-body .hub-plain, .hub-row-body .hub-dev { color: var(--ink-soft); font-size: var(--text-ui); max-width: 80ch; } -.hub-rows .row-meta { max-width: 16rem; } -.hub-section { margin-top: 48px; } -.hub-section-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 20px; } -.hub-items { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: 8px; margin-top: 16px; } -.hub-item { - display: flex; flex-direction: column; gap: 10px; padding: 18px 20px; scroll-margin-top: calc(var(--nav) + 24px); - background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); -} -.hub-item:target { box-shadow: var(--panel-edges), 0 0 0 3px var(--accent); } -.hub-item-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } -.hub-item-meta .badge { margin-right: 0; } -.hub-ongoing { font: var(--text-micro) / 1 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.hub-item-title { font-size: var(--text-heading); line-height: var(--text-heading-line); } -.hub-item .hub-plain, .hub-item .hub-dev { font-size: var(--text-ui); color: var(--ink-soft); } -.hub-progress { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--ink); } -.hub-links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 4px; } -.hub-link { - display: inline-flex; gap: 6px; align-items: baseline; padding: 3px 7px; font: var(--text-micro) / 1.3 var(--mono); - color: var(--ink-soft); background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); -} -.hub-link span { color: var(--muted); } -.hub-link:hover { color: var(--accent); } -.hub-link.is-merged { background: color-mix(in srgb, var(--success) 18%, var(--well)); } -.hub-link.is-in-review { background: color-mix(in srgb, var(--accent) 18%, var(--well)); } -.hub-link.is-in-progress { background: color-mix(in srgb, var(--warn) 22%, var(--well)); } -.hub-link.is-merged span, .hub-link.is-in-review span, .hub-link.is-in-progress span { color: var(--ink); } -.hub-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 8px; } -.hub-cards .highlight { justify-content: flex-start; } -.hub-cards h3 { font-size: var(--text-heading); line-height: var(--text-heading-line); } -.hub-cards p { color: var(--ink-soft); font-size: var(--text-ui); } -.hub-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } -.hub-cards .hub-actions { margin-top: auto; } -.hub-rules { list-style: decimal; padding-left: 20px; max-width: var(--measure); } -.hub-rules li + li { margin-top: 10px; } -.hub-rules li::marker { color: var(--muted); font-family: var(--mono); } -.hub-source { margin-top: 40px; max-width: var(--measure); } -.hub-board-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; } -.hub-board-head .hub-intro { margin-bottom: 0; } -.hub-select { display: inline-flex; align-items: center; gap: 10px; } -.hub-select select { - height: 32px; max-width: 22rem; padding: 0 10px; font: var(--text-label) / 1 var(--font); color: var(--ink); - background: var(--well); border: 0; box-shadow: 0 0 0 var(--block-outline) var(--outline); -} -.hub-board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 24px; align-items: start; } -.hub-column { padding: 14px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); } -.hub-column h3 { display: flex; justify-content: space-between; align-items: baseline; font-size: var(--text-heading); } -.hub-count { font: var(--text-micro) / 1 var(--mono); color: var(--muted); } -.hub-column > .meta { margin: 6px 0 12px; } -.hub-cards-list { display: grid; gap: 6px; } -.js .hub-column:not(.is-open) .hub-card[data-more] { display: none; } -.hub-card a { display: grid; gap: 4px; padding: 10px 12px; background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); color: var(--ink); } -.hub-card a:hover { box-shadow: 0 0 0 var(--block-outline) var(--accent); } -.hub-card-ref, .hub-card-meta { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.hub-card-title { font-size: var(--text-label); line-height: 1.4; overflow-wrap: anywhere; } -.hub-card-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; } -.hub-card-item { color: var(--accent); } -.hub-more { margin-top: 10px; width: 100%; justify-content: center; } -.hub-journal { display: grid; gap: 8px; margin-top: 24px; max-width: 56rem; } -.hub-entry { display: grid; gap: 10px; padding: 18px 20px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); scroll-margin-top: calc(var(--nav) + 24px); } -.hub-entry-week { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); } -.hub-entry-meta { display: flex; align-items: center; gap: 10px; font: var(--text-micro) / 1 var(--mono); color: var(--muted); } -.hub-entry-meta .badge { margin-right: 0; } -.hub-entry-title { font-size: var(--text-heading); line-height: var(--text-heading-line); } -.hub-entry-title a { color: var(--ink); } -.hub-entry-title a:hover { color: var(--accent); } -.hub-entry .hub-plain, .hub-entry .hub-dev { font-size: var(--text-ui); color: var(--ink-soft); } -.hub-entry-items { display: flex; flex-wrap: wrap; gap: 6px; } -.hub-shipped { font-size: var(--text-ui); color: var(--ink); } -.hub-changes { list-style: square; padding-left: 18px; font-size: var(--text-ui); color: var(--ink-soft); } -.hub-changes li + li { margin-top: 4px; } -.hub-ref { font: var(--text-micro) / 1 var(--mono); } -.hub-group { margin-top: 8px; font: 500 var(--text-label) / 1.4 var(--font); } -.hub-group:first-child { margin-top: 0; } -@media (max-width: 1100px) { .hub-board { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 720px) { - .hub-board { grid-template-columns: minmax(0, 1fr); } - .hub-rows .row > a { grid-template-columns: var(--index) minmax(0, 1fr); } - .hub-rows .row-meta { grid-column: 2; text-align: left; } -} - -/* --- Hub: votes, the report and suggestion forms, the contributors ---------------------- */ -.hub-vote { margin-left: auto; } -.hub-vote-count { margin-left: -2px; } -.hub-vote-count:empty { display: none; } -.hub-form { display: grid; gap: 16px; max-width: 46rem; margin-top: 8px; } -.hub-form-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } -.hub-field { display: grid; gap: 6px; font-size: var(--text-label); color: var(--ink-soft); } -.hub-field input, .hub-field textarea, .hub-field select { - width: 100%; min-height: 36px; padding: 8px 10px; font: var(--text-ui) / 1.4 var(--font); color: var(--ink); - background: var(--well); border: 0; box-shadow: 0 0 0 var(--block-outline) var(--outline); -} -.hub-field textarea { resize: vertical; } -.hub-field input:focus-visible, .hub-field textarea:focus-visible, .hub-field select:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; } -.hub-field input::placeholder, .hub-field textarea::placeholder { color: var(--muted); } -.hub-trap { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; } -.hub-form-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; } -.hub-form-foot [data-status] { color: var(--ink); } -.hub-form-foot .button:disabled { opacity: 0.4; cursor: default; } -.hub-form-closed { color: var(--ink-soft); } -@media (max-width: 720px) { - .hub-form-pair { grid-template-columns: minmax(0, 1fr); } -} - -/* The registry (/mods): a search and the kind filter, rows as panels, then a page per project. */ -.mod-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 24px; } -.mod-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-top: 32px; } -.mod-tools .release-filter { margin-top: 0; } -.mod-search { - height: 36px; min-width: min(320px, 100%); padding: 0 12px; font: var(--text-ui) / 1 var(--sans); color: var(--ink); - background: var(--well); border: var(--block-outline) solid var(--outline); border-radius: 0; -} -.mod-search:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; } -.mod-list { display: grid; gap: 8px; } -.mod-row { - display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 14px 20px; - background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); color: var(--ink); text-decoration: none; -} -.mod-row:hover { background: var(--raised); } -.mod-row:hover .mod-name { color: var(--accent); } -.mod-icon { - display: grid; place-items: center; width: 48px; height: 48px; flex: none; image-rendering: pixelated; - font: 600 22px / 1 var(--pixel); color: var(--ink); background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); -} -.mod-text { display: grid; gap: 4px; min-width: 0; } -.mod-name { font: 500 var(--text-heading) / 1.2 var(--pixel); } -.mod-summary { color: var(--muted); font-size: var(--text-ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.mod-side { display: grid; justify-items: end; gap: 6px; } -.mod-side .badge { margin-right: 0; } -.mod-version { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.mod-empty a { color: var(--accent); } -.badge-signed { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); } -.badge-unsigned { background: color-mix(in srgb, var(--warn) 24%, var(--panel)); color: var(--ink); } -.mod-page { display: grid; gap: 16px; max-width: calc(var(--measure) + 240px); } -.mod-page .heading { margin-top: 24px; } -.mod-head { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 24px; align-items: start; } -.mod-owners { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 0; } -.mod-owner { display: inline-flex; align-items: center; gap: 6px; } -.mod-owner img { border-radius: 50%; } -.mod-head .mod-icon { width: 96px; height: 96px; font-size: 44px; } -.mod-head .page-title { margin-top: 0; } -.mod-badges { margin-top: 16px; } -.mod-caps, .mod-steps, .mod-versions { display: grid; gap: 8px; padding-left: 20px; font-size: var(--text-ui); } -.mod-caps li::marker, .mod-steps li::marker { color: var(--accent); } -.mod-file { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 16px; font-size: var(--text-ui); } -.mod-file dt { color: var(--muted); } -.mod-file code, .mod-steps code { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere; } -.mod-links { display: flex; flex-wrap: wrap; gap: 24px; margin-top: 24px; } -/* /mods/submit */ -.mod-form { display: grid; gap: 24px; max-width: var(--measure); margin-top: 32px; } -.mod-form fieldset { display: grid; gap: 16px; margin: 0; padding: 0; border: 0; } -.mod-form legend { padding: 0; margin-bottom: 8px; } -.mod-field { display: grid; gap: 6px; } -.mod-label { font-size: var(--text-ui); font-weight: 500; } -.mod-form .mod-search { width: 100%; } -.mod-form textarea.mod-search { height: auto; padding: 10px 12px; line-height: 1.5; resize: vertical; } -.mod-check { display: flex; gap: 10px; align-items: flex-start; font-size: var(--text-ui); } -.mod-auth { margin-top: 24px; } -.mod-error { color: var(--danger); font-size: var(--text-ui); } -.js .no-script { display: none; } -@media (max-width: 640px) { - .mod-row { grid-template-columns: 40px minmax(0, 1fr); } - .mod-side { grid-column: 2; justify-items: start; } - .mod-icon { width: 40px; height: 40px; } - .mod-head { grid-template-columns: 1fr; } -} diff --git a/website/src/versions.js b/website/src/versions.js deleted file mode 100644 index b2483b4ed..000000000 --- a/website/src/versions.js +++ /dev/null @@ -1,59 +0,0 @@ -// Horizon's release tags (docs/versioning.md). From 0.2 the game is vX.Y.Z and the launcher launcher@X.Y.Z; -// before, vYYYY.MM.DD[.N] and launcher-vX.Y.Z. Every 0.2 tag is newer than every tag from before. Shared by -// build.mjs and main.js, and the same order as the launcher's and tools/release_train.py's. - -const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; -// Semver's majors stay below this; a date tag's first number is a year. -const FIRST_YEAR = 1000; - -const semver = (version) => { - const m = SEMVER.exec(version); - return m && Number(m[1]) < FIRST_YEAR ? m : null; -}; - -export const isGame = (tag) => /^v\d{4}\.\d{1,2}\.\d{1,2}(\.\d+)?$/.test(tag) || (tag.startsWith("v") && !!semver(tag.slice(1))); - -// The version in a launcher tag (launcher@0.2.0, launcher-v0.16.0), or null. -export function launcherVersion(tag) { - if (tag.startsWith("launcher@") && semver(tag.slice(9))) return tag.slice(9); - const old = /^launcher-v(\d+\.\d+\.\d+)$/.exec(tag); - return old ? old[1] : null; -} - -export const isLauncher = (tag) => launcherVersion(tag) !== null; - -// [line, numbers, pre-release]: tags from before 0.2 by their numbers, then 0.2 tags in semver's order. -function key(tag) { - const version = tag.startsWith("launcher@") ? tag.slice(9) : tag.startsWith("v") ? tag.slice(1) : null; - const m = version !== null && semver(version); - if (!m) return [0, (tag.match(/\d+/g) || []).map(Number), []]; - const pre = m[4] ? m[4].split(".").map((id) => (/^\d+$/.test(id) ? [0, Number(id)] : [1, id])) : null; - return [1, [m[1], m[2], m[3]].map(Number), pre]; -} - -const cmpList = (a, b, cmp) => { - for (let i = 0; i < Math.min(a.length, b.length); i++) { - const d = cmp(a[i], b[i]); - if (d) return d; - } - return a.length - b.length; -}; -const cmpNumbers = (a, b) => { - const strip = (l) => { const c = [...l]; while (c.length && c[c.length - 1] === 0) c.pop(); return c; }; - return cmpList(strip(a), strip(b), (x, y) => x - y); -}; -const cmpId = ([ka, va], [kb, vb]) => ka - kb || (ka ? (va < vb ? -1 : va > vb ? 1 : 0) : va - vb); - -// Negative when tag a is older than tag b. -export function compareTags(a, b) { - const [la, na, pa] = key(a); - const [lb, nb, pb] = key(b); - if (la !== lb) return la - lb; - const d = cmpNumbers(na, nb); - if (d || !la) return d; - // A release follows its pre-releases. - if (!pa || !pb) return (pa ? -1 : 0) - (pb ? -1 : 0); - return cmpList(pa, pb, cmpId); -} - -export const newestFirst = (a, b) => compareTags(b, a); diff --git a/website/src/versions.test.mjs b/website/src/versions.test.mjs deleted file mode 100644 index eed334429..000000000 --- a/website/src/versions.test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -// node --test website/src/ (npm test in website/) -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { compareTags, isGame, isLauncher, launcherVersion, newestFirst } from "./versions.js"; - -test("0.2 tags follow every tag from before", () => { - const order = ["v2026.10.19.15", "v2026.10.21", "v0.2.0-beta.1", "v0.2.0", "v0.2.1-beta", "v0.2.1-beta.2", - "v0.2.1-beta.11", "v0.2.1-rc.1", "v0.2.1", "v0.2.9", "v0.2.10", "v0.3.0-1", "v0.3.0-alpha", "v0.3.0", "v1.0.0"]; - assert.deepEqual([...order].reverse().sort(compareTags), order); - assert.deepEqual(["launcher-v0.16.0", "launcher@0.2.0", "launcher-v0.17.0", "launcher@0.2.1"].sort(newestFirst), - ["launcher@0.2.1", "launcher@0.2.0", "launcher-v0.17.0", "launcher-v0.16.0"]); - assert.equal(compareTags("v2026.10.19", "v2026.10.19.0"), 0); -}); - -test("game and launcher tags never mix", () => { - for (const tag of ["v0.2.0", "v0.3.0-rc.1", "v2026.10.21", "v2026.10.19.15"]) assert.ok(isGame(tag), tag); - for (const tag of ["launcher@0.2.0", "launcher-v0.16.0", "v0.2", "vnext", "v2026.10.21-x"]) assert.ok(!isGame(tag), tag); - assert.equal(launcherVersion("launcher@0.2.0"), "0.2.0"); - assert.equal(launcherVersion("launcher-v0.16.0"), "0.16.0"); - assert.ok(!isLauncher("v0.2.0") && !isLauncher("launcher-vnext")); -}); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2682b1f82..b71910da4 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -10,7 +10,7 @@ //! (`cargo build --release -p horizon-client --no-default-features`, without sound or controllers) //! as horizon-engine, with horizon-prepare and horizon-server from beside it and the same sources. //! -//! `tokens`: writes `website/src/tokens.css`, the design tokens of `crates/horizon-theme` as CSS +//! `tokens`: writes `crates/horizon-theme/tokens.css`, the design tokens of `crates/horizon-theme` as CSS //! custom properties (a horizon-theme test fails when the file is out of date). //! //! `icons`: renders the icon masters in `docs/media/brand` (with `rsvg-convert`, from librsvg) @@ -79,9 +79,9 @@ fn git(repo: &Path, args: &[&str]) -> Result, String> { } fn tokens() -> Result<(), String> { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../website/src/tokens.css"); + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../crates/horizon-theme/tokens.css"); fs::write(&path, horizon_theme::css()).map_err(|e| format!("{}: {e}", path.display()))?; - println!("wrote website/src/tokens.css"); + println!("wrote crates/horizon-theme/tokens.css"); Ok(()) } @@ -195,7 +195,7 @@ fn icons() -> Result<(), String> { &repo.join("crates/horizon-theme/icons/horizon-64.png"), &render(&square, (64, 64))?, )?; - // Avatars and the website's icons. + // Avatars, and the website's icons (horizon-site copies docs/media/brand/web/ into its public/). let avatar = brand.join("avatar.svg"); for (svg, size, out) in [ ( @@ -203,15 +203,15 @@ fn icons() -> Result<(), String> { 500, "docs/media/brand/social/github-avatar-500.png", ), - (&avatar, 180, "website/public/apple-touch-icon.png"), - (&square, 32, "website/public/favicon-32.png"), + (&avatar, 180, "docs/media/brand/web/apple-touch-icon.png"), + (&square, 32, "docs/media/brand/web/favicon-32.png"), (&square, 1024, "docs/media/brand/icon-1024.png"), (&macos, 1024, "docs/media/brand/icon-macos-1024.png"), ] { write(&repo.join(out), &render(svg, (size, size))?)?; } write( - &repo.join("website/public/favicon.svg"), + &repo.join("docs/media/brand/web/favicon.svg"), &fs::read(&square).map_err(|e| format!("{}: {e}", square.display()))?, )?; println!("wrote packaging/icons, the window icon, the avatars and the website's icons");