diff --git a/.github/workflows/game-build.yml b/.github/workflows/game-build.yml new file mode 100644 index 000000000..65acf1b28 --- /dev/null +++ b/.github/workflows/game-build.yml @@ -0,0 +1,274 @@ +name: game build +# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on +# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by +# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the +# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job, +# which never runs what this one built. See docs/release-flow.md. +on: + workflow_call: + inputs: + ref: + description: The commit to build + type: string + required: true + version: + description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28 + type: string + required: true + nightly: + description: A nightly's " " (HORIZON_NIGHTLY), shown by the game; empty for a release + type: string + default: '' + developer_id: + description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain) + type: boolean + default: true +permissions: + contents: read +jobs: + exporters: + # This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. + runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }} + # Three full preparations (two isolated, one batched) besides the compile. + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + clean: true + - uses: dtolnay/rust-toolchain@stable + - run: bash tools/ci/runner-env.sh + - run: bash tools/ci/vanilla-data.sh + - name: Compile and validate original exporters against the pinned jars + shell: bash + run: | + set -euo pipefail + # target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it + # prepares afresh. + cargo build --release --locked -p horizon-prepare + # Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs + # write those (vanilla-data.sh links .local/exporters to them). + rm -rf .local/exporters + bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1) + mkdir -p exporters + cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/ + # Only our classes and their manifest leave this runner. + stray=$(find exporters -type f ! -path exporters/manifest.json \ + ! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \)) + [ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; } + python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare + - uses: actions/upload-artifact@v4 + with: + name: original-exporters + path: exporters/ + if-no-files-found: error + retention-days: 3 + + build: + needs: exporters + name: build ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-22.04 + dir: horizon-linux-x86_64 + asset: horizon-linux-x86_64.tar.gz + # The server build too: packaging/server/install.sh installs it on Linux. + server_dir: horizon-server-linux-x86_64 + server_asset: horizon-server-linux-x86_64.tar.gz + - name: windows + os: windows-latest + dir: horizon-windows-x64 + asset: horizon-windows-x64.zip + exe: .exe + # The C runtime linked in, so the game runs without the Visual C++ redistributable. + rustflags: -C target-feature=+crt-static + - name: macos + mac: true + dir: horizon-macos-arm64 + asset: horizon-macos-arm64.zip + runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }} + # Notarizing the zip can take a few minutes. + timeout-minutes: ${{ matrix.mac && 60 || 40 }} + env: + RUSTFLAGS: ${{ matrix.rustflags }} + # The release's version, built in (horizon_core::release): --version, user agents, crash reports and + # telemetry say it. No version-bump commits. + HORIZON_VERSION: ${{ inputs.version }} + # A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release. + HORIZON_NIGHTLY: ${{ inputs.nightly }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + # Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests' + # parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output) + # may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which + # keeps its preparations outside the checkout. + clean: true + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + if: runner.environment == 'github-hosted' + with: + shared-key: release-${{ matrix.name }} + - name: ALSA and udev headers + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev + - name: Build the client, horizon-ctl, horizon-prepare and horizon-server + # `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds). + run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server + - name: Every program the release folder takes was built + run: | + for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do + test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; } + done + - name: The signing keychain, unlocked + if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id + env: + MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }} + run: packaging/macos/keychain.sh unlock + - name: Signed with Developer ID and the hardened runtime + # Before the checks below, so they run the programs as players will. + if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id + env: + MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }} + run: | + for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do + codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \ + --identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary" + codesign --verify --strict --verbose=2 "target/release/$binary" + done + - name: Only system libraries + # Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard + # loader and libraries every desktop distribution has. macOS: only the system's own libraries. + run: | + for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do + file="target/release/$binary${{ matrix.exe }}" + if [[ "$RUNNER_OS" == macOS ]]; then + others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true) + [[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; } + else + ${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file" + fi + done + - name: Every program starts + run: | + set -euo pipefail + bin=target/release + "$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources' + "$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null + "$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir' + # The version is the release's. + for binary in horizon-prepare horizon-server; do + out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r') + echo "$out" + [[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; } + done + - name: Tools from the runner + if: runner.environment == 'self-hosted' + run: bash tools/ci/runner-env.sh + - name: Resources prepared from the runner's own Minecraft + if: runner.environment == 'self-hosted' + run: bash tools/ci/vanilla-data.sh + - name: The game runs (offscreen, a new world, 1500 frames) + if: runner.environment == 'self-hosted' + run: | + set -euo pipefail + # From a scratch folder: the game keeps its saves and options in the folder it starts in. + home="$RUNNER_TEMP/horizon-smoke" + rm -rf "$home" && mkdir -p "$home" + version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1) + client="$PWD/target/release/horizon-client" + resources="$PWD/.local/horizon-client/$version" + (cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \ + --world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \ + --smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; } + python3 - "$home/smoke.json" <<'EOF' + import json, sys + report = json.load(open(sys.argv[1])) + print(json.dumps(report)) + if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \ + or not report.get("meshed_sections") or not report.get("player"): + sys.exit("the smoke run did not draw a world") + EOF + # Published with the release; the publish job checks it again before it publishes anything. + cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json" + - uses: actions/upload-artifact@v4 + if: runner.environment == 'self-hosted' + with: + name: smoke-test.json + path: ${{ runner.temp }}/smoke-test.json + if-no-files-found: error + retention-days: 3 + - uses: actions/download-artifact@v4 + with: + name: original-exporters + path: exporters + - name: Package + run: | + set -euo pipefail + cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}" + cd dist + rm -f "${{ matrix.asset }}" + case "${{ matrix.asset }}" in + *.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;; + *) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null + else + # Without resource forks and extended attributes: plain ditto adds an + # AppleDouble `._` file beside every file that has any. + ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}" + if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then + echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1 + fi + fi ;; + esac + ls -l "${{ matrix.asset }}" + - name: Notarized + # A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs. + if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id + run: | + for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do + codesign --verify --strict "dist/${{ matrix.dir }}/$binary" + done + packaging/macos/notarize.sh "dist/${{ matrix.asset }}" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: dist/${{ matrix.asset }} + if-no-files-found: error + retention-days: 3 + - name: The server build (the game without sound or controllers, as horizon-engine) + if: matrix.server_asset + run: | + set -euo pipefail + # After the client is packaged: target/release/horizon-client is rebuilt without the audio and + # controllers features, so it needs neither libasound nor libudev (minimal server images lack them), + # and with code mods like the client. + cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client + cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}" + folder="dist/${{ matrix.server_dir }}" + python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare" + "$folder/horizon-engine" --help | grep -q -- '--resources' + [[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]] + cd dist + rm -f "${{ matrix.server_asset }}" + tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}" + ls -l "${{ matrix.server_asset }}" + - uses: actions/upload-artifact@v4 + if: matrix.server_asset + with: + name: ${{ matrix.server_asset }} + path: dist/${{ matrix.server_asset }} + if-no-files-found: error + retention-days: 3 + - name: The signing keychain, locked again + if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id + run: packaging/macos/keychain.sh lock + diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml new file mode 100644 index 000000000..d674d5992 --- /dev/null +++ b/.github/workflows/nightly-build.yml @@ -0,0 +1,166 @@ +name: nightly build +# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night it builds the newest +# union branch on origin (union/-: main plus pull requests the contribution gate proved together), or +# main when no union is newer, and publishes it as the `nightly` pre-release. Launchers install it only for players +# who turn Nightly on. The previous nightly stays a week as nightly-. +# +# Why the signing key never meets the union's code: +# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and +# `uses: ./…` takes the caller's commit. The union's own .github/ is never read. +# - `build` (game-build.yml) checks out and compiles the union with no secrets: its token reads contents only and +# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain, +# which stays locked for nightlies). +# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which +# runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message +# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's. +# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves +# the tag. Like sign, it never executes what build produced. +# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's) +# gh workflow run "nightly build" -f force=true # build and publish even if unchanged +# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing +on: + schedule: + - cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini + workflow_dispatch: + inputs: + force: + description: Build and publish even if tonight's pick is the commit already published + type: boolean + default: false + dry_run: + description: Build and sign with a throwaway key, publish nothing + type: boolean + default: false +permissions: + contents: read +concurrency: + group: nightly-build + cancel-in-progress: false +jobs: + pick: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + build: ${{ steps.pick.outputs.build }} + sha: ${{ steps.pick.outputs.sha }} + source: ${{ steps.pick.outputs.source }} + date: ${{ steps.pick.outputs.date }} + version: ${{ steps.pick.outputs.version }} + nightly: ${{ steps.pick.outputs.nightly }} + dry_run: ${{ steps.mode.outputs.dry_run }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - id: mode + # A dry run never publishes; a hand-started run off main is always one. + run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT" + - id: pick + env: + GH_TOKEN: ${{ github.token }} + FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }} + run: | + python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY" + { echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + with: + name: nightly-notes + path: nightly-notes.md + retention-days: 3 + + build: + needs: pick + if: needs.pick.outputs.build == 'true' + uses: ./.github/workflows/game-build.yml + with: + ref: ${{ needs.pick.outputs.sha }} + version: ${{ needs.pick.outputs.version }} + nightly: ${{ needs.pick.outputs.nightly }} + developer_id: false + + sign: + needs: [pick, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + # The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL. + environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }} + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: tools/ci/sign_release.sh + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + path: dist + pattern: horizon-* + merge-multiple: true + - name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel + env: + RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }} + EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }} + run: | + bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \ + dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz + - uses: actions/upload-artifact@v4 + with: + name: signature + path: dist/SHA256SUMS* + if-no-files-found: error + retention-days: 3 + - uses: actions/upload-artifact@v4 + if: needs.pick.outputs.dry_run == 'true' + with: + name: ephemeral.pub + path: dist/ephemeral.pub + retention-days: 3 + + publish: + needs: [pick, build, sign] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: The signature, checked as launchers check it + env: + EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }} + run: | + key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)") + cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist + - name: The smoke test drew a world + # No release-blocker check: a nightly is how blockers get tested. + run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json + - name: Publish as the `nightly` pre-release + if: needs.pick.outputs.dry_run != 'true' + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ needs.pick.outputs.sha }} + DATE: ${{ needs.pick.outputs.date }} + run: | + set -euo pipefail + python3 tools/ci/nightly.py rotate + signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig) + # A pre-release, never GitHub's latest: launchers on the release channel never see it. + gh release create nightly --prerelease --latest=false --target "$SHA" \ + --title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \ + "dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \ + "dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \ + "dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \ + "dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \ + dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json + python3 tools/ci/nightly.py prune --days 7 + - name: Dry run, the nightly that would be published + if: needs.pick.outputs.dry_run == 'true' + run: | + echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})" + ls -l dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d879f0d61..92738c330 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,10 @@ permissions: # The open release-blocker issues (decide). issues: read concurrency: - group: game-release + # One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a + # group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub + # cancelled it (#2779's dry run, 2026-10-27), which happens all day while releases go out in batches. + group: game-release${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '-dry-run' || '' }} cancel-in-progress: false jobs: decide: @@ -79,252 +82,14 @@ jobs: # A dry run builds whatever the decision, to test the builds. if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi - exporters: + build: needs: decide if: needs.decide.outputs.release == 'true' - # This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. - runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }} - # Three full preparations (two isolated, one batched) besides the compile. - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.decide.outputs.head }} - persist-credentials: false - clean: true - - uses: dtolnay/rust-toolchain@stable - - run: bash tools/ci/runner-env.sh - - run: bash tools/ci/vanilla-data.sh - - name: Compile and validate original exporters against the pinned jars - shell: bash - run: | - set -euo pipefail - # target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it - # prepares afresh. - cargo build --release --locked -p horizon-prepare - # Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs - # write those (vanilla-data.sh links .local/exporters to them). - rm -rf .local/exporters - bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1) - mkdir -p exporters - cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/ - # Only our classes and their manifest leave this runner. - stray=$(find exporters -type f ! -path exporters/manifest.json \ - ! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \)) - [ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; } - python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare - - uses: actions/upload-artifact@v4 - with: - name: original-exporters - path: exporters/ - if-no-files-found: error - retention-days: 3 - - build: - needs: [decide, exporters] - if: needs.decide.outputs.release == 'true' - name: build ${{ matrix.name }} - strategy: - fail-fast: false - matrix: - include: - - name: linux - os: ubuntu-22.04 - dir: horizon-linux-x86_64 - asset: horizon-linux-x86_64.tar.gz - # The server build too: packaging/server/install.sh installs it on Linux. - server_dir: horizon-server-linux-x86_64 - server_asset: horizon-server-linux-x86_64.tar.gz - - name: windows - os: windows-latest - dir: horizon-windows-x64 - asset: horizon-windows-x64.zip - exe: .exe - # The C runtime linked in, so the game runs without the Visual C++ redistributable. - rustflags: -C target-feature=+crt-static - - name: macos - mac: true - dir: horizon-macos-arm64 - asset: horizon-macos-arm64.zip - runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }} - # Notarizing the zip can take a few minutes. - timeout-minutes: ${{ matrix.mac && 60 || 40 }} - env: - RUSTFLAGS: ${{ matrix.rustflags }} - # The release's version, built in (horizon_core::release): --version, user agents, crash reports and - # telemetry say it. No version-bump commits. - HORIZON_VERSION: ${{ needs.decide.outputs.version }} - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.decide.outputs.head }} - persist-credentials: false - # Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests' - # parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output) - # may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which - # keeps its preparations outside the checkout. - clean: true - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - if: runner.environment == 'github-hosted' - with: - shared-key: release-${{ matrix.name }} - - name: ALSA and udev headers - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev - - name: Build the client, horizon-ctl, horizon-prepare and horizon-server - # `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds). - run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server - - name: Every program the release folder takes was built - run: | - for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do - test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; } - done - - name: The signing keychain, unlocked - if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' - env: - MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }} - run: packaging/macos/keychain.sh unlock - - name: Signed with Developer ID and the hardened runtime - # Before the checks below, so they run the programs as players will. - if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' - env: - MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }} - run: | - for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do - codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \ - --identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary" - codesign --verify --strict --verbose=2 "target/release/$binary" - done - - name: Only system libraries - # Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard - # loader and libraries every desktop distribution has. macOS: only the system's own libraries. - run: | - for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do - file="target/release/$binary${{ matrix.exe }}" - if [[ "$RUNNER_OS" == macOS ]]; then - others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true) - [[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; } - else - ${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file" - fi - done - - name: Every program starts - run: | - set -euo pipefail - bin=target/release - "$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources' - "$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null - "$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir' - # The version is the release's. - for binary in horizon-prepare horizon-server; do - out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r') - echo "$out" - [[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; } - done - - name: Tools from the runner - if: runner.environment == 'self-hosted' - run: bash tools/ci/runner-env.sh - - name: Resources prepared from the runner's own Minecraft - if: runner.environment == 'self-hosted' - run: bash tools/ci/vanilla-data.sh - - name: The game runs (offscreen, a new world, 1500 frames) - if: runner.environment == 'self-hosted' - run: | - set -euo pipefail - # From a scratch folder: the game keeps its saves and options in the folder it starts in. - home="$RUNNER_TEMP/horizon-smoke" - rm -rf "$home" && mkdir -p "$home" - version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1) - client="$PWD/target/release/horizon-client" - resources="$PWD/.local/horizon-client/$version" - (cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \ - --world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \ - --smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; } - python3 - "$home/smoke.json" <<'EOF' - import json, sys - report = json.load(open(sys.argv[1])) - print(json.dumps(report)) - if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \ - or not report.get("meshed_sections") or not report.get("player"): - sys.exit("the smoke run did not draw a world") - EOF - # Published with the release; the publish job checks it again before it publishes anything. - cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json" - - uses: actions/upload-artifact@v4 - if: runner.environment == 'self-hosted' - with: - name: smoke-test.json - path: ${{ runner.temp }}/smoke-test.json - if-no-files-found: error - retention-days: 3 - - uses: actions/download-artifact@v4 - with: - name: original-exporters - path: exporters - - name: Package - run: | - set -euo pipefail - cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}" - cd dist - rm -f "${{ matrix.asset }}" - case "${{ matrix.asset }}" in - *.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;; - *) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null - else - # Without resource forks and extended attributes: plain ditto adds an - # AppleDouble `._` file beside every file that has any. - ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}" - if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then - echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1 - fi - fi ;; - esac - ls -l "${{ matrix.asset }}" - - name: Notarized - # A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs. - if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' - run: | - for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do - codesign --verify --strict "dist/${{ matrix.dir }}/$binary" - done - packaging/macos/notarize.sh "dist/${{ matrix.asset }}" - - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.asset }} - path: dist/${{ matrix.asset }} - if-no-files-found: error - retention-days: 3 - - name: The server build (the game without sound or controllers, as horizon-engine) - if: matrix.server_asset - run: | - set -euo pipefail - # After the client is packaged: target/release/horizon-client is rebuilt without the audio and - # controllers features, so it needs neither libasound nor libudev (minimal server images lack them), - # and with code mods like the client. - cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client - cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}" - folder="dist/${{ matrix.server_dir }}" - python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare" - "$folder/horizon-engine" --help | grep -q -- '--resources' - [[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]] - cd dist - rm -f "${{ matrix.server_asset }}" - tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}" - ls -l "${{ matrix.server_asset }}" - - uses: actions/upload-artifact@v4 - if: matrix.server_asset - with: - name: ${{ matrix.server_asset }} - path: dist/${{ matrix.server_asset }} - if-no-files-found: error - retention-days: 3 - - name: The signing keychain, locked again - if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' - run: packaging/macos/keychain.sh lock + # Every platform's archive, and macOS's smoke test (game-build.yml). + uses: ./.github/workflows/game-build.yml + with: + ref: ${{ needs.decide.outputs.head }} + version: ${{ needs.decide.outputs.version }} sign: needs: [decide, build] diff --git a/crates/horizon-client-core/src/component.rs b/crates/horizon-client-core/src/component.rs index 5b828b305..afb68fa98 100644 --- a/crates/horizon-client-core/src/component.rs +++ b/crates/horizon-client-core/src/component.rs @@ -173,6 +173,22 @@ fn object_style(o: &serde_json::Map) -> Style { if let Some(e) = o.get("click_event").and_then(Value::as_object) { let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned); style.click = match e.get("action").and_then(Value::as_str) { + // ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and + // https only, the scheme in any case (Util.parseAndValidateUntrustedUri). + Some("open_url") => text("url") + .filter(|u| { + u.split_once(':').is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) + }) + .map(crate::text::ClickEvent::OpenUrl), + // ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT. + Some("change_page") => e + .get("page") + .and_then(Value::as_i64) + .and_then(|p| i32::try_from(p).ok()) + .filter(|p| *p >= 1) + .map(crate::text::ClickEvent::ChangePage), Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand), Some("copy_to_clipboard") => { text("value").map(crate::text::ClickEvent::CopyToClipboard) diff --git a/crates/horizon-client-core/src/text.rs b/crates/horizon-client-core/src/text.rs index 0a2b6eadb..3a0bf301b 100644 --- a/crates/horizon-client-core/src/text.rs +++ b/crates/horizon-client-core/src/text.rs @@ -42,6 +42,10 @@ pub fn named_color(name: &str) -> Option { /// Click actions carried by locally constructed text components. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ClickEvent { + /// `ClickEvent.OpenUrl`: an http or https link. + OpenUrl(String), + /// `ClickEvent.ChangePage`: a book's page, counted from 1. + ChangePage(i32), SuggestCommand(String), CopyToClipboard(String), /// A local file to open with the system handler. @@ -529,6 +533,8 @@ fn style_json(style: &Style) -> Vec<(String, Value)> { } if let Some(click) = &style.click { let event = match click { + ClickEvent::OpenUrl(url) => json!({ "action": "open_url", "url": url }), + ClickEvent::ChangePage(page) => json!({ "action": "change_page", "page": page }), ClickEvent::SuggestCommand(command) => { json!({ "action": "suggest_command", "command": command }) } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 64ff4defd..f3b3835d5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -22,6 +22,7 @@ mod bundles; mod campfire; mod campfire_douse; mod cartography; +mod cat_spawner; mod chat; mod client_ambient; mod clipboard; @@ -1283,6 +1284,8 @@ struct Game<'a> { pending_pack_reload: bool, /// A server's resource packs (game/server_packs.rs). server_packs: server_packs::ServerPackState, + /// The link a chat click asks to open (`ConfirmLinkScreen`). + link_prompt: Option, dialogs: dialogs::DialogState, world_renderer_ready: bool, collision: &'a BlockCollisionRegistry<'a>, @@ -5069,9 +5072,14 @@ impl Game<'_> { if let Some(host) = &self.lan { host.hub.entity_event(host.host_entity, 3, feet); } - self.screen = Screen::Dead; - self.capture(false); - self.ui.death_time = 0; + if self.ui.rules.bool("immediate_respawn") { + // Gui.setScreen: without the death screen the player respawns at once. + self.respawn_player()?; + } else { + self.screen = Screen::Dead; + self.capture(false); + self.ui.death_time = 0; + } } else if dead { // LivingEntity.tickDeath: 20 ticks after dying the player is // removed, and Player.remove's inventoryMenu.removed drops what @@ -5543,6 +5551,7 @@ impl Game<'_> { }; self.draw_dialog(&mut batch); self.draw_pack_prompt(&mut batch); + self.draw_link_prompt(&mut batch); // Gui.extractRenderState: the debug overlay comes after the screen // and the toasts, so it is over everything, menus included. self.draw_debug_overlay(&mut batch, size); @@ -8673,6 +8682,7 @@ impl Game<'_> { self.spawn_spiral = Default::default(); self.player_spawn = None; self.creatures.reset_trader(); + self.creatures.cat_tick = 0; self.portals = Default::default(); self.experience = Experience { level: self.level.xp_level, @@ -12343,6 +12353,11 @@ impl ApplicationHandler for Game<'_> { self.advancements_scroll(scaled(raw_x) as f32, d); } } + WindowEvent::MouseInput { state, button, .. } if self.link_prompt.is_some() => { + if state == ElementState::Pressed && button == MouseButton::Left { + self.link_prompt_click(); + } + } WindowEvent::MouseInput { state, button, .. } if self.server_packs.prompt.is_some() => { @@ -12572,6 +12587,13 @@ impl ApplicationHandler for Game<'_> { } } // ConfirmScreen.keyPressed: Escape answers no. + WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => { + if event.state == ElementState::Pressed + && event.physical_key == PhysicalKey::Code(KeyCode::Escape) + { + self.answer_link_prompt(false); + } + } WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => { if event.state == ElementState::Pressed && event.physical_key == PhysicalKey::Code(KeyCode::Escape) @@ -13105,6 +13127,7 @@ impl<'a> Game<'a> { world_loader: None, pending_pack_reload: false, server_packs: Default::default(), + link_prompt: None, dialogs: Default::default(), world_renderer_ready: false, collision: res.collision(), diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index b09803e49..ddbe47481 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -8,7 +8,7 @@ use horizon_client::{ containers::{BlockEntity, Lectern}, first_person::Hand, hud::{HudBatch, rgba}, - item_data::WrittenBook, + item_data::{StackData, WrittenBook}, items::ItemStack, menu::{Kind, Menu}, text::Text, @@ -59,14 +59,86 @@ pub(super) enum BookScreen { page_edit: ui::EditBox, }, View { - pages: Vec, + pages: Vec, page: usize, lectern: Option<(i32, i32, i32)>, }, } +/// The pages a book screen reads: a written book's components (so their +/// click events stay), else a writable book's text. +fn view_pages(data: Option<&StackData>) -> Vec { + let Some(data) = data else { return Vec::new() }; + let Some(w) = &data.written else { + return data.writable_pages.iter().map(Text::literal).collect(); + }; + let tagged = w.page_tags.len() == w.pages.len(); + w.pages + .iter() + .enumerate() + .map(|(i, page)| match tagged.then(|| &w.page_tags[i]) { + Some(tag @ horizon_world::nbt::Tag::Compound(_)) => { + horizon_client::entity_nbt::name_text(tag) + } + _ => Text::literal(page), + }) + .collect() +} impl Game<'_> { fn book_lines(&self, page: &str) -> Vec { - self.gui.atlas.font.split(&Text::literal(page), 114.0) + self.page_lines(&Text::literal(page)) + } + fn page_lines(&self, page: &Text) -> Vec { + self.gui.atlas.font.split(page, 114.0) + } + /// `BookViewScreen.mouseClicked`: the click event of the text under the + /// cursor on the page. + fn book_link_at(&self, page: &Text, left: f32) -> Option { + let (mx, my) = self.cursor; + let row = ((my - 32.0) / 9.0).floor(); + if row < 0.0 || row >= LINES as f32 { + return None; + } + let lines = self.page_lines(page); + let line = lines.get(row as usize)?.resolve_legacy(); + let mut x = mx - (left + 36.0); + if x < 0.0 { + return None; + } + for (c, style) in line.chars() { + x -= self.gui.atlas.font.advance(c, style); + if x < 0.0 { + return style.click.clone(); + } + } + None + } + /// `BookViewScreen.handleClickEvent`: a page link turns to its page + /// (`forcePage`), a web link goes through the confirm-link flow. + fn book_follow_link( + &mut self, + link: horizon_client::text::ClickEvent, + count: usize, + lectern: Option<(i32, i32, i32)>, + ) { + use horizon_client::text::ClickEvent; + match link { + ClickEvent::ChangePage(target) => { + let target = (target - 1).clamp(0, count.saturating_sub(1) as i32); + let Some(BookScreen::View { page, .. }) = self.book.as_mut() else { + return; + }; + if let Some(key) = lectern { + // LecternScreen.forcePage: the menu's button 100 + page. + if target != *page as i32 { + self.lectern_button(key, 100 + target); + } + } else if target != *page as i32 { + *page = target as usize; + } + } + ClickEvent::OpenUrl(url) => self.click_url(url), + _ => {} + } } /// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`). /// A server's player writes in its own client's editor @@ -115,11 +187,7 @@ impl Game<'_> { } } "minecraft:written_book" => BookScreen::View { - pages: stack - .data() - .and_then(|d| d.written.as_ref()) - .map(|w| w.pages.clone()) - .unwrap_or_default(), + pages: view_pages(stack.data()), page: 0, lectern: None, }, @@ -215,6 +283,7 @@ impl Game<'_> { author, generation: 0, pages, + page_tags: Vec::new(), }); *stack = written; } else { @@ -685,11 +754,7 @@ impl Game<'_> { return false; }; let Some(book) = &l.book else { return false }; - let data = book.data(); - let pages = data - .and_then(|d| d.written.as_ref().map(|w| w.pages.clone())) - .or_else(|| data.map(|d| d.writable_pages.clone())) - .unwrap_or_default(); + let pages = view_pages(book.data()); let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1)); self.stats.custom("interact_with_lectern", 1); self.open_book(BookScreen::View { @@ -811,11 +876,7 @@ impl Game<'_> { page, }) = self.book.as_mut() { - let data = book.as_ref().and_then(ItemStack::data); - *pages = data - .and_then(|d| d.written.as_ref().map(|w| w.pages.clone())) - .or_else(|| data.map(|d| d.writable_pages.clone())) - .unwrap_or_default(); + *pages = view_pages(book.as_ref().and_then(ItemStack::data)); *page = (*page).min(pages.len().saturating_sub(1)); } } @@ -955,7 +1016,10 @@ impl Game<'_> { lectern, }) => { let (page, count, lectern) = (*page, pages.len(), *lectern); - if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) { + // The text's click event first, then the buttons. + if let Some(link) = pages.get(page).and_then(|t| self.book_link_at(t, left)) { + self.book_follow_link(link, count, lectern); + } else if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) { self.turn_page(true); } else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) { self.turn_page(false); @@ -1198,7 +1262,7 @@ impl Game<'_> { } => { indicator(batch, *page, pages.len()); if let Some(text) = pages.get(*page) { - for (i, line) in self.book_lines(text).iter().take(LINES).enumerate() { + for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() { batch.text_styled( atlas, line, diff --git a/crates/horizon-client/src/game/cat_spawner.rs b/crates/horizon-client/src/game/cat_spawner.rs new file mode 100644 index 000000000..539aae726 --- /dev/null +++ b/crates/horizon-client/src/game/cat_spawner.rs @@ -0,0 +1,138 @@ +//! `CatSpawner`: every 1200 ticks a cat may appear near a random player, +//! in a village with enough occupied beds or inside a swamp hut. +use super::{ + Dim, Game, + raids::{section, sections_to_village}, +}; +use horizon_client::mobs::{Species, spawn_rules}; +use horizon_core::{geometry::Vec3, random::RandomSource}; + +const TICK_DELAY: i32 = 1200; + +impl Game<'_> { + /// `CatSpawner.tick` (the overworld's custom spawner). + pub(super) fn tick_cat_spawner(&mut self) { + if self.dim != Dim::Overworld { + return; + } + self.creatures.cat_tick -= 1; + if self.creatures.cat_tick > 0 { + return; + } + self.creatures.cat_tick = TICK_DELAY; + // ServerLevel.getRandomPlayer: one of the living players. + let alive: Vec = self + .level_players() + .into_iter() + .filter(|p| p.alive) + .map(|p| p.position) + .collect(); + if alive.is_empty() { + return; + } + let pick = self + .world + .random + .next_int_bound(alive.len() as i32) + .unwrap_or(0); + let p = alive[pick as usize]; + let mut offset = || { + let n = 8 + self.world.random.next_int_bound(24).unwrap_or(0); + if self.world.random.next_boolean() { + -n + } else { + n + } + }; + let x = offset(); + let z = offset(); + let pos = [ + p.x.floor() as i32 + x, + p.y.floor() as i32, + p.z.floor() as i32 + z, + ]; + // hasChunksAt(pos +- 10) + let loaded = ((pos[0] - 10) >> 4..=(pos[0] + 10) >> 4).all(|cx| { + ((pos[2] - 10) >> 4..=(pos[2] + 10) >> 4) + .all(|cz| self.world.chunks.contains_key(&(cx, cz))) + }); + if !loaded { + return; + } + let ok = spawn_rules::is_spawn_position_ok( + Species::Cat, + &self.mob_world_base(&[]), + pos[0], + pos[1], + pos[2], + ); + if !ok { + return; + } + if sections_to_village(&self.village_centers(), section(pos)) <= 2 { + self.spawn_cat_in_village(pos); + } else { + let hut = self.structure_tag("minecraft:cats_spawn_in"); + if self.structures_at(pos).iter().any(|s| hut.contains(s)) { + self.spawn_cat_in_hut(pos); + } + } + } + /// `spawnInVillage`: more than four occupied beds within 48 blocks and + /// fewer than five cats around. + fn spawn_cat_in_village(&mut self, pos: [i32; 3]) { + // PoiManager.getCountInRange(HOME, IS_OCCUPIED): beds villagers have claimed. + let mut beds: Vec<[i32; 3]> = self + .mobs + .iter() + .filter(|m| m.species() == Species::Villager && m.alive()) + .filter_map(|m| m.extra.bed) + .filter(|b| { + let d = |i: usize| i64::from(b[i] - pos[i]); + d(0).abs() <= 48 + && d(2).abs() <= 48 + && d(0).pow(2) + d(1).pow(2) + d(2).pow(2) <= 48 * 48 + }) + .collect(); + beds.sort_unstable(); + beds.dedup(); + if beds.len() > 4 && self.cats_near(pos, 48.0) < 5 { + self.spawn_cat(pos, false); + } + } + /// `spawnInHut`: no cat within 16 blocks; the cat stays. + fn spawn_cat_in_hut(&mut self, pos: [i32; 3]) { + if self.cats_near(pos, 16.0) == 0 { + self.spawn_cat(pos, true); + } + } + /// Cats in `AABB(pos).inflate(radius, 8, radius)`. + fn cats_near(&self, pos: [i32; 3], radius: f64) -> usize { + let [x, y, z] = pos.map(f64::from); + let min = Vec3::new(x - radius, y - 8.0, z - radius); + let max = Vec3::new(x + 1.0 + radius, y + 9.0, z + 1.0 + radius); + self.mobs + .iter() + .filter(|m| { + m.species() == Species::Cat && m.alive() && m.bounds().intersects_corners(min, max) + }) + .count() + } + /// `spawnCat`: finalized for a natural spawn, black inside + /// `#cats_spawn_as_black` structures (`Cat.finalizeSpawn`). + fn spawn_cat(&mut self, pos: [i32; 3], persistent: bool) { + let at = [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]), + f64::from(pos[2]) + 0.5, + ]; + let Some(id) = self.spawn_mob("minecraft:cat", at, persistent) else { + return; + }; + let black = self.structure_tag("minecraft:cats_spawn_as_black"); + let in_black = self.structures_at(pos).iter().any(|s| black.contains(s)); + if in_black && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { + m.extra.variant = 10; + } + } +} diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index c392f3ea6..7344c0e9d 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -695,12 +695,78 @@ impl Game<'_> { Some(ClickEvent::OpenFile(path)) => { super::packs::open_folder(std::path::Path::new(&path)); } + Some(ClickEvent::OpenUrl(url)) => self.click_url(url), Some(ClickEvent::SuggestCommand(command)) => { if let Some(screen) = self.ui.chat_screen.as_mut() { screen.suggest_command(&m, &c, &command); } } - None => {} + // Screen.defaultHandleClickEvent: a page link means nothing in chat. + Some(ClickEvent::ChangePage(_)) | None => {} + } + } + /// `Screen.clickUrlAction`: nothing with Web Links off, else the link + /// opens, after the confirmation unless Prompt on Links is off. + pub(super) fn click_url(&mut self, url: String) { + if !self.settings.chat_links { + return; + } + if self.settings.file.bool("chatLinksPrompt") { + self.link_prompt = Some(url); + } else { + super::packs::open_uri(&url); + } + } + /// `ConfirmLinkScreen` (untrusted): the link and its warning over the + /// chat, answered yes to open it. + fn link_prompt_layout( + &self, + url: &str, + size: (f32, f32), + ) -> horizon_client::confirm_screen::Confirm { + let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC); + let message = Text::literal(url) + .append(Text::literal("\n")) + .append(warning); + horizon_client::confirm_screen::Confirm::new( + &self.gui.atlas.font, + size, + &Text::translate("chat.link.confirm", &[]), + &message, + ) + } + pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) { + let Some(url) = &self.link_prompt else { + return; + }; + let atlas = &self.gui.atlas; + let (w, h) = (batch.width, batch.height); + horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let (yes, no) = ( + Text::translate("gui.yes", &[]), + Text::translate("gui.no", &[]), + ); + self.link_prompt_layout(url, (w, h)) + .draw(batch, atlas, self.cursor, [&yes, &no]); + } + /// The prompt's answer: the link opens on yes; either way the chat is back. + pub(super) fn answer_link_prompt(&mut self, yes: bool) { + if let Some(url) = self.link_prompt.take() + && yes + { + super::packs::open_uri(&url); + } + } + pub(super) fn link_prompt_click(&mut self) { + let Some(url) = &self.link_prompt else { + return; + }; + let answer = self + .link_prompt_layout(url, self.gui_size()) + .answer_at(self.cursor); + if let Some(yes) = answer { + self.audio.play_button_click(); + self.answer_link_prompt(yes); } } /// Chat lines over the HUD (focused while the chat screen is open). diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 34319d462..225948e31 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -46,6 +46,9 @@ pub(super) struct Creatures { pub projectiles: Vec, /// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs). pub wind_bursts: Vec, + /// Mace smash knockback on the other players from a guest's turn, waiting + /// for it to end (game/mace.rs). + pub smash_pushes: Vec, /// Explosion packets from a guest's turn, waiting for it to end /// (centre, small, radius, blocks hit; game/lan_particles.rs). pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>, @@ -53,6 +56,8 @@ pub(super) struct Creatures { pub elder_apparition: i32, /// The wandering trader spawner's clock. pub trader: super::wandering_trader::Spawner, + /// The cat spawner's countdown (`CatSpawner.nextTick`). + pub cat_tick: i32, /// Particles from a guest's turn, waiting for it to end /// (`lan_send_particles`: options, centre, spread, speed, count). pub lan_particles: Vec<( @@ -499,6 +504,7 @@ impl Game<'_> { if self.ui.rules.bool("spawn_mobs") { self.tick_wandering_trader(); self.tick_patrol_spawner(); + self.tick_cat_spawner(); } self.tick_raids(); self.tick_creature_entities() @@ -3166,6 +3172,7 @@ impl Game<'_> { at: pos, setup, }])?; + self.damage_held_tool(); } Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => { m.extra.sheared = true; @@ -3544,23 +3551,9 @@ impl Game<'_> { } true } + /// `ItemStack.hurtAndBreak(1, ...)` on the held tool: Unbreaking, break sound and stat. pub(super) fn damage_held_tool(&mut self) { - if self.creative { - return; - } - let slot = self.held_slot(); - let max_damage = self - .held() - .and_then(|s| self.res.items().get(&s.item)) - .map_or(0, |i| i.max_damage); - if max_damage > 0 - && let Some(stack) = self.stack_in_mut(slot).as_mut() - { - stack.damage += 1; - if stack.damage >= max_damage { - *self.stack_in_mut(slot) = None; - } - } + self.damage_held(1); } /// Throwables and spawn eggs (SnowballItem/EggItem/EnderpearlItem/SpawnEggItem.use). pub(super) fn throw_item(&mut self, stack: &ItemStack) -> Result { diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index da9aaafce..250887b30 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -50,6 +50,9 @@ pub(super) struct Fight { respawn: Option<(Respawn, i32)>, /// The four crystals on the exit portal driving the respawn. respawn_crystals: Vec, + /// The player behind the crystal blast under way, whose `explosion` + /// damage source names them (`EndCrystal.hurtServer`). + blast_by: Option, dirty: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -264,6 +267,9 @@ pub(super) struct Dragon { /// only damage above the last hit lands, and only the excess. invulnerable: i32, last_hurt: f32, + /// `lastHurtByPlayer` with its 100 tick memory: the player credited with + /// the kill, and the dragon tick of their last hit. + hurt_by_player: Option<(FightWho, i32)>, pub death_time: i32, pub flap: f32, pub old_flap: f32, @@ -389,6 +395,7 @@ impl Dragon { hurt_time: 0, invulnerable: 0, last_hurt: 0.0, + hurt_by_player: None, death_time: 0, flap: 0.0, old_flap: 0.0, @@ -431,6 +438,10 @@ impl Dragon { pub fn alive(&self) -> bool { self.health > 0.0 } + /// `isDeadOrDying` for the killing blow: the dying flight, or no health. + fn down(&self) -> bool { + matches!(self.phase, Phase::Dying { .. }) || !self.alive() + } fn part_box(&self, i: usize) -> Aabb { let (w, h) = PART_SIZES[i]; let p = self.parts[i].0; @@ -898,8 +909,12 @@ impl Game<'_> { .any(|p| p.distance_squared(c.position) < 0.01); if damage == CrystalDamage::Direct { let at = [c.position.x, c.position.y, c.position.z]; + // The blast's damage source carries the player, so the dragon + // remembers them before the blast can kill it. + self.fight.blast_by = by; let _ = self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false); + self.fight.blast_by = None; } if respawn_crystal { self.abort_respawn(); @@ -910,7 +925,7 @@ impl Game<'_> { if let Some(d) = self.dragon.as_mut() { if d.nearest_crystal == Some(index) { d.nearest_crystal = None; - self.hurt_dragon(0, 10.0, true, false); + self.hurt_dragon(0, 10.0, true, by, false); } else if let Some(n) = d.nearest_crystal.as_mut() && *n > index { @@ -982,17 +997,43 @@ impl Game<'_> { self.destroy_crystal(index, by, CrystalDamage::Direct); Hurt::Landed { full: true } } else if id >= SPECIAL { - self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow) + let by = by_player.then(|| self.acting_player()); + self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow) } else { Hurt::Refused } } /// `EnderDragon.hurt(part, source, damage)` on the dragon in the level. - fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt { + /// `by` is the player behind the hit, remembered for the kill credit. + fn hurt_dragon( + &mut self, + part: usize, + damage: f32, + counts: bool, + by: Option, + arrow: bool, + ) -> Hurt { let Some(d) = self.dragon.as_mut() else { return Hurt::Refused; }; + let was_down = d.down(); + let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt); let hurt = d.hurt(part, damage, counts, arrow); + // LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer + // (100 ticks); a hit the damage cooldown absorbs returns before that. + if counts + && matches!(hurt, Hurt::Landed { .. }) + && (d.invulnerable != cooldown || d.last_hurt != last_hurt) + && let Some(who) = by + { + d.hurt_by_player = Some((who, d.ticks)); + } + if !was_down && d.down() { + self.credit_dragon_kill(); + } + let Some(d) = self.dragon.as_mut() else { + return hurt; + }; if hurt == (Hurt::Landed { full: true }) { // Mob.playHurtSound: the ambient sound clock starts again. d.ambient_time = -AMBIENT_INTERVAL; @@ -1003,6 +1044,47 @@ impl Game<'_> { } hurt } + /// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player + /// who hurt the dragon within the last 100 ticks. A guest's kill is + /// theirs: their statistics, scores and advancements. + fn credit_dragon_kill(&mut self) { + let Some(d) = self.dragon.as_ref() else { + return; + }; + let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else { + return; + }; + let view = horizon_client::advancements::EntityView { + kind: "minecraft:ender_dragon".to_owned(), + position: [d.position.x, d.position.y, d.position.z], + ..Default::default() + }; + let id = d.id; + let guest = match who { + FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn), + _ => None, + }; + let credited = guest.is_some_and(|conn| { + let Some(mut host) = self.lan.take() else { + return false; + }; + let done = self + .as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view)) + .is_some(); + self.lan = Some(host); + done + }); + if !credited { + self.award_dragon_kill(id, &view); + } + } + /// `ServerPlayer.awardKillScore` for the dragon, on the killer's state. + fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) { + self.stats.add("killed", "minecraft:ender_dragon", 1); + self.stats.custom("mob_kills", 1); + self.score_player_killed(super::commands::Target::Mob(id)); + self.trigger_entity("minecraft:player_killed_entity", "entity", view); + } /// Player melee on the dragon's parts or a crystal under the crosshair. /// The dragon part under the crosshair within reach: its id and distance. pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> { @@ -2780,8 +2862,9 @@ impl Game<'_> { let (lo, hi) = (b.min_position(), b.max_position()); self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]) }); + let by = self.fight.blast_by; for (part, amount) in hits { - self.hurt_dragon(part, amount, true, false); + self.hurt_dragon(part, amount, true, by, false); } } } diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index cb546e9c8..5688a3226 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -351,8 +351,10 @@ impl Game<'_> { continue; } hits.push(1.0 + self.world.random.next_float() * 4.0); + // ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies. + let loss = self.durability_loss(&stack, 2); if let Some(s) = self.armor[slot].as_mut() { - s.damage += 2; + s.damage += loss; if self .res .items() diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index d91de60f0..506c6c419 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1169,6 +1169,8 @@ impl Game<'_> { self.ui.rules.bool("player_movement_check"), self.ui.rules.bool("elytra_movement_check"), ); + host.hub + .set_immediate_respawn(self.ui.rules.bool("immediate_respawn")); // A server with several levels runs a pass for each // (`tick_server`): the network is read once, before the first. let pass = self.level_pass; diff --git a/crates/horizon-client/src/game/mace.rs b/crates/horizon-client/src/game/mace.rs index 34c968980..80f09a331 100644 --- a/crates/horizon-client/src/game/mace.rs +++ b/crates/horizon-client/src/game/mace.rs @@ -37,6 +37,41 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool { || m.species() == Species::Allay && m.extra.tame } +/// A smash's knockback on the players, put off while a guest's turn holds +/// the other players. +pub(super) struct SmashPush { + center: Vec3, + heavy: f64, + attacker: Option, + team: Option, +} +/// `MaceItem.getKnockbackPower` and the push of one entity at `at`, `heavy` +/// being 2 after a fall of more than five blocks. +fn smash_push(center: Vec3, heavy: f64, at: Vec3, velocity: &mut Vec3, resistance: f64) { + let d = [at.x - center.x, at.y - center.y, at.z - center.z]; + let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + if distance_sqr > KNOCKBACK_RADIUS.powi(2) { + return; + } + let length = distance_sqr.sqrt(); + let power = + (KNOCKBACK_RADIUS - length) * f64::from(KNOCKBACK_POWER) * heavy * (1.0 - resistance); + if power <= 0.0 { + return; + } + // Vec3.normalize: a vector shorter than 1e-5 has no direction. + let (x, z) = if length < f64::from(1.0e-5f32) { + (0.0, 0.0) + } else { + (d[0] / length * power, d[2] / length * power) + }; + *velocity = Vec3::new( + velocity.x + x, + velocity.y + f64::from(KNOCKBACK_POWER), + velocity.z + z, + ); +} + impl Game<'_> { fn holds_mace(&self) -> bool { self.inventory[self.selected] @@ -220,44 +255,75 @@ impl Game<'_> { ]; self.level_event_at(2013, on_pos, 750); let heavy = if fall > HEAVY_THRESHOLD { 2.0 } else { 1.0 }; - // getKnockbackPower and the push of one entity at `at`. - let push = |at: Vec3, velocity: &mut Vec3, resistance: f64| { - let d = [at.x - center.x, at.y - center.y, at.z - center.z]; - let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; - if distance_sqr > KNOCKBACK_RADIUS.powi(2) { - return; - } - let length = distance_sqr.sqrt(); - let power = (KNOCKBACK_RADIUS - length) - * f64::from(KNOCKBACK_POWER) - * heavy - * (1.0 - resistance); - if power <= 0.0 { - return; - } - // Vec3.normalize: a vector shorter than 1e-5 has no direction. - let (x, z) = if length < f64::from(1.0e-5f32) { - (0.0, 0.0) - } else { - (d[0] / length * power, d[2] / length * power) - }; - *velocity = Vec3::new( - velocity.x + x, - velocity.y + f64::from(KNOCKBACK_POWER), - velocity.z + z, - ); - }; let player = self.level.player_uuid.or(Some([0, 0, 0, 1])); for m in self .mobs .iter_mut() .filter(|m| m.id != id && m.alive() && !m.removed && !ally_of_player(m, player)) { - push(m.position, &mut m.velocity, m.attrs.knockback_resistance); + smash_push( + center, + heavy, + m.position, + &mut m.velocity, + m.attrs.knockback_resistance, + ); } for s in self.armor_stands.iter_mut().filter(|s| !s.marker) { - push(s.position, &mut s.velocity, 0.0); + smash_push(center, heavy, s.position, &mut s.velocity, 0.0); } + // The host and the LAN guests are living entities too. A guest's turn + // holds the other players (`as_guest`): the push waits for it to end. + let push = SmashPush { + center, + heavy, + attacker: self.lan_acting, + team: self.player_team().cloned(), + }; + if self.lan_acting.is_some() { + self.creatures.smash_pushes.push(push); + } else { + self.smash_push_players(&push); + } + } + /// `MaceItem.knockback` on the players but the attacker: the host unless + /// it attacked, and every guest but the attacker. + fn smash_push_players(&mut self, push: &SmashPush) { + if push.attacker.is_some() { + self.smash_push_player(push); + } + self.each_guest_hit_except(push.attacker, |g| g.smash_push_player(push)); + } + /// The pushes guests' smashes put off. + pub(super) fn flush_smash_pushes(&mut self) { + for push in std::mem::take(&mut self.creatures.smash_pushes) { + self.smash_push_players(&push); + } + } + /// `MaceItem.knockback` on the player here (the host, or the guest swapped + /// in): not a spectator, an ally of the attacker (`isAlliedTo`: the same + /// team) or a creative player flying. + fn smash_push_player(&mut self, push: &SmashPush) { + if self.spectator() + || horizon_client::scoreboard::teams_allied(push.team.as_ref(), self.player_team()) + { + return; + } + let creative = self.creative; + let Some(p) = self.player.as_mut() else { + return; + }; + if creative && p.flying { + return; + } + let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0); + smash_push( + push.center, + push.heavy, + p.position, + &mut p.velocity, + resistance, + ); } /// `ServerPlayer.checkFallDamage` after a smash on a grounded target: /// the landing block's particles, 50 for every block fallen (200 at most). diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs index 0035f5082..5afa72416 100644 --- a/crates/horizon-client/src/game/natural_spawning.rs +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -504,7 +504,8 @@ impl<'a> Game<'a> { m.position.z.floor() as i32, ], is_mob: true, - persistent: m.persistent, + persistent: m.persistent + || horizon_client::mobs::ai::requires_custom_persistence(m), }) .collect(); let players = players diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 117352e10..8720cb8fa 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -203,6 +203,8 @@ pub(super) struct Remote { stop_mining: bool, placing: Option<([i32; 3], u64)>, death_message: Option, + /// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`. + show_death_screen: bool, player_type: Option>, /// Inventory as last synchronised, for creative-mode slot updates. synced: Vec>, @@ -496,6 +498,7 @@ impl Game<'_> { stop_mining: false, placing: None, death_message: None, + show_death_screen: true, player_type, synced: vec![None; 36], decode_errors: 0, @@ -1664,6 +1667,7 @@ impl Game<'_> { r.online_mode = p.online_mode; r.entity_id = p.player_id; r.view_distance = p.chunk_radius; + r.show_death_screen = p.show_death_screen; } self.streaming.radius = self.settings.render_distance.min(p.chunk_radius + 1).max(2); @@ -2358,7 +2362,11 @@ impl Game<'_> { pl.health.flash_health_change(p.health); } self.apply_remote_health(); - if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() { + // The death screen opens (or the respawn is asked for) with the + // death packet when the rule hides it. + let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen); + if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some() + { self.screen = Screen::Dead; self.ui.death_time = 0; self.capture(false); @@ -2374,11 +2382,16 @@ impl Game<'_> { r.death_message = Some(message); } // ClientPacketListener.handlePlayerCombatKill: the death - // screen, whatever health was last sent. + // screen, whatever health was last sent, or `respawn()` at + // once when it is hidden. if me && self.screen != Screen::Dead && self.player.is_some() { - self.screen = Screen::Dead; - self.ui.death_time = 0; - self.capture(false); + if self.remote.as_ref().is_none_or(|r| r.show_death_screen) { + self.screen = Screen::Dead; + self.ui.death_time = 0; + self.capture(false); + } else { + self.respawn_player()?; + } } } C::TrackedWaypoint(p) => self.handle_waypoint(&p), @@ -2454,6 +2467,12 @@ impl Game<'_> { r.game_mode = mode; } } + // IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen. + 11 => { + if let Some(r) = self.remote.as_mut() { + r.show_death_screen = p.param == 0.0; + } + } 7 => { self.world.weather.rain_level = (p.param, p.param); self.world.weather.raining = p.param > 0.0; diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index 750895adb..4b5c4c333 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -322,6 +322,58 @@ impl Game<'_> { ); } } + /// LocalPlayer.respawn: the player comes back (the death screen's + /// button, or at once with `immediate_respawn`). + pub(super) fn respawn_player(&mut self) -> Result<()> { + if self.remote.is_none() { + // PlayerList.respawn removes the dead player, and Player.remove's + // inventoryMenu.removed drops what the cursor held, whatever + // keep_inventory says (the 20-tick branch handles a later respawn). + if let Some(stack) = self.carried.take() + && let Some(eye) = self.eye_position() + { + self.throw_from_eyes(stack, eye, false); + } + let keep = self.ui.rules.bool("keep_inventory") || self.spectator(); + self.experience.respawn(keep); + // The guests are sent the respawned host as a new entity, not the dead body. + if let Some(host) = self.lan.as_mut() { + let id = host.host_entity; + host.hub.respawned(id); + } + // Autosaves keep a login position; death must resolve the respawn point instead. + self.level.position = None; + } + if self.remote.is_some() { + self.ui.death_text = None; + self.toggles.respawn(); + self.remote_respawn(); + } else if self.respawn_config.is_some() { + // LocalPlayer.respawn: KeyMapping.resetToggleKeys. + self.toggles.respawn(); + // PlayerList.respawn at the respawn anchor, once it is loaded. + self.begin_anchor_respawn()?; + self.screen = Screen::Playing; + self.ui.death_text = None; + self.spawn_player()?; + self.capture(true); + } else { + // LocalPlayer.respawn: KeyMapping.resetToggleKeys. + self.toggles.respawn(); + self.respawn_out_of_end()?; + self.player = None; + self.screen = Screen::Playing; + self.ui.death_text = None; + self.spawn_player()?; + self.capture(true); + } + // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's + // respawned player only watches. + if self.remote.is_none() && self.level.hardcore { + self.set_mode(GameMode::Spectator); + } + Ok(()) + } pub(super) fn death_click(&mut self) -> Result<()> { if self .ui @@ -339,51 +391,11 @@ impl Game<'_> { .into_iter() .find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0)) .map(|d| d.3); - if hit == Some(0) && self.remote.is_none() { - let keep = self.ui.rules.bool("keep_inventory") || self.spectator(); - self.experience.respawn(keep); - // The guests are sent the respawned host as a new entity, not the dead body. - if let Some(host) = self.lan.as_mut() { - let id = host.host_entity; - host.hub.respawned(id); - } - // Autosaves keep a login position; death must resolve the respawn point instead. - self.level.position = None; - } match hit { - Some(0) if self.remote.is_some() => { - self.ui.death_text = None; - self.toggles.respawn(); - self.remote_respawn(); - } - Some(0) if self.respawn_config.is_some() => { - // LocalPlayer.respawn: KeyMapping.resetToggleKeys. - self.toggles.respawn(); - // PlayerList.respawn at the respawn anchor, once it is loaded. - self.begin_anchor_respawn()?; - self.screen = Screen::Playing; - self.ui.death_text = None; - self.spawn_player()?; - self.capture(true); - } - Some(0) => { - // LocalPlayer.respawn: KeyMapping.resetToggleKeys. - self.toggles.respawn(); - self.respawn_out_of_end()?; - self.player = None; - self.screen = Screen::Playing; - self.ui.death_text = None; - self.spawn_player()?; - self.capture(true); - } + Some(0) => self.respawn_player()?, Some(1) => self.close_world()?, _ => {} } - // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's - // respawned player only watches. - if hit == Some(0) && self.remote.is_none() && self.level.hardcore { - self.set_mode(GameMode::Spectator); - } if hit.is_some() { self.audio.play_button_click(); } diff --git a/crates/horizon-client/src/game/shield.rs b/crates/horizon-client/src/game/shield.rs index c4f7f98d1..a26bc5d75 100644 --- a/crates/horizon-client/src/game/shield.rs +++ b/crates/horizon-client/src/game/shield.rs @@ -182,12 +182,17 @@ impl Game<'_> { return; }; let (mx, mz) = (m.position.x, m.position.z); - let disable = m - .extra - .main_hand - .as_deref() - .and_then(|item| self.res.items().get(item)) - .map_or(0.0, |i| i.disable_blocking_seconds); + // LivingEntity.getSecondsToDisableBlocking: the held weapon's value; + // Warden overrides it with a flat 5 seconds. + let disable = if m.species() == horizon_client::mobs::Species::Warden { + 5.0 + } else { + m.extra + .main_hand + .as_deref() + .and_then(|item| self.res.items().get(item)) + .map_or(0.0, |i| i.disable_blocking_seconds) + }; // Ravager.blockedByItem: stunned half the time (the defender pushed // apart), else the defender flung away; no roar in progress. if m.species() == horizon_client::mobs::Species::Ravager { diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index a5104ee12..bca582d1a 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -6,6 +6,7 @@ use super::{Game, view_vector}; use horizon_client::{ collisions::SceneCollisions, + enchant::EffectContext, items::ItemStack, mesher::Vertex, mobs::{Damage, DamageKind, Who, anim::Poses}, @@ -392,11 +393,29 @@ impl Game<'_> { } } if let Some((id, _)) = hit_mob { - let impaling = t.stack.enchantment_level("minecraft:impaling") as f32; - let aquatic = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| { - m.kind.flags.aquatic_swimmer || m.kind.flags.breathes_water - }); - let damage = 8.0 + if aquatic { 2.5 * impaling } else { 0.0 }; + // ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8 + // (impaling hits `#sensitive_to_impaling`, the aquatic tag). + let victim = self + .mobs + .iter() + .find(|m| m.id == id) + .map(|m| m.kind.id.clone()); + let ctx = EffectContext { + this: victim.as_deref(), + attacker: Some("minecraft:player"), + direct_attacker: Some("minecraft:trident"), + is_direct: true, + damage_type: Some("minecraft:trident"), + items: Some(self.res.items()), + ..Default::default() + }; + let damage = self.res.stations().enchantments.modify_value( + Some(&t.stack), + "minecraft:damage", + &ctx, + &mut self.world.random, + 8.0, + ); t.dealt_damage = true; let mut hurt_at = None; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { diff --git a/crates/horizon-client/src/game/wind.rs b/crates/horizon-client/src/game/wind.rs index 63ac2641d..b00cea116 100644 --- a/crates/horizon-client/src/game/wind.rs +++ b/crates/horizon-client/src/game/wind.rs @@ -470,6 +470,7 @@ impl Game<'_> { } /// The bursts guests' turns put off. pub(super) fn flush_wind_bursts(&mut self) -> Result<()> { + self.flush_smash_pushes(); for b in std::mem::take(&mut self.creatures.wind_bursts) { self.wind_burst(b.center, b.radius, b.multiplier, b.breeze, b.wind_charge)?; } diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-client/src/item_data.rs index 27a28902a..1bd80437f 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-client/src/item_data.rs @@ -143,6 +143,9 @@ pub struct WrittenBook { pub author: String, pub generation: u8, pub pages: Vec, + /// Each page's component when it is not a plain string, so that its + /// click events survive; empty or one per page. + pub page_tags: Vec, } impl StackData { pub fn is_empty(&self) -> bool { @@ -322,6 +325,21 @@ fn read_levels(t: Option<&Tag>) -> Levels { } /// A text component as stored by vanilla: a plain string or `{"text": ...}`. /// A `Filterable`: the value itself or `{raw: value}`. +/// `ComponentSerialization.CODEC`'s short form: a plain text component with +/// no style and no siblings is written as its string. +fn plain_component(t: &Tag) -> Tag { + if let Tag::Compound(c) = t + && let Some(text @ Tag::String(_)) = c.get(&"text".into()) + && c.iter().all(|(k, v)| { + k.to_string_lossy() == "text" + || (k.to_string_lossy() == "type" + && matches!(v, Tag::String(s) if s.to_string_lossy() == "text")) + }) + { + return text.clone(); + } + t.clone() +} fn filterable_text(t: &Tag) -> Option { match t.get("raw") { Some(raw) => read_text(raw), @@ -553,11 +571,14 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option { )); } if let Some(w) = &d.written { - let pages = w - .pages - .iter() - .map(|p| raw(Tag::String(p.as_str().into()))) - .collect(); + let pages = if w.page_tags.len() == w.pages.len() { + w.page_tags.iter().cloned().map(raw).collect() + } else { + w.pages + .iter() + .map(|p| raw(Tag::String(p.as_str().into()))) + .collect() + }; entries.push(( "minecraft:written_book_content", Tag::compound({ @@ -738,6 +759,13 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option pages.iter().filter_map(filterable_text).collect(), _ => Vec::new(), }, + page_tags: match b.get("pages") { + Some(Tag::List(pages)) => pages + .iter() + .map(|p| plain_component(p.get("raw").unwrap_or(p))) + .collect(), + _ => Vec::new(), + }, }), fireworks: c .get("minecraft:fireworks") diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 2deb5905e..9f83e1457 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -669,11 +669,14 @@ fn network_stack( title: w.title.clone(), author: w.author.clone(), generation: i32::from(w.generation), - pages: w - .pages - .iter() - .map(|p| horizon_protocol::text::literal(p)) - .collect(), + pages: if w.page_tags.len() == w.pages.len() { + w.page_tags.clone() + } else { + w.pages + .iter() + .map(|p| horizon_protocol::text::literal(p)) + .collect() + }, resolved: true, }, ), diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 7395cc0a4..12967d778 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -43,6 +43,7 @@ pub mod game_event { pub const RAIN_LEVEL: u8 = 7; pub const THUNDER_LEVEL: u8 = 8; pub const GUARDIAN_ELDER_EFFECT: u8 = 10; + pub const IMMEDIATE_RESPAWN: u8 = 11; pub const LEVEL_CHUNKS_LOAD_START: u8 = 13; } @@ -172,6 +173,8 @@ pub struct Hub { /// The `player_movement_check` and `elytra_movement_check` game rules, /// set by the game before each tick. pub movement_checks: (bool, bool), + /// The `immediate_respawn` game rule, as the players were last told. + immediate_respawn: bool, /// An integrated server's owner (`MinecraftServer.isSingleplayerOwner`), /// whose moves are never checked. pub singleplayer_owner: Option, @@ -352,6 +355,7 @@ impl Hub { guests: Vec::new(), sync, ticks: 0, + immediate_respawn: false, movement_checks: (true, true), singleplayer_owner: None, riding: Default::default(), @@ -429,6 +433,17 @@ impl Hub { g.sender.send(body.clone()); } } + /// MinecraftServer.onGameRuleChanged: every player is told when + /// `immediate_respawn` changes; a joining player's Login carries it. + pub fn set_immediate_respawn(&mut self, on: bool) { + if self.immediate_respawn != on { + self.immediate_respawn = on; + self.broadcast(&C::GameEvent(play::GameEvent { + event: game_event::IMMEDIATE_RESPAWN, + param: if on { 1.0 } else { 0.0 }, + })); + } + } /// PlayerList.setViewDistance: every player is told the new radius, /// and each one's chunks follow it, up to what they asked for /// (ChunkMap.getPlayerViewDistance). @@ -663,7 +678,7 @@ impl Hub { chunk_radius: self.config.view_distance, simulation_distance: self.config.simulation_distance, reduced_debug_info: false, - show_death_screen: true, + show_death_screen: !self.immediate_respawn, limited_crafting: false, spawn: self.spawn_info(mode), // MinecraftServer.usesAuthentication, enforceSecureProfile. diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index a0d0b84ca..ad130ea68 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -1201,6 +1201,19 @@ pub fn despawn_check(m: &mut Mob, w: &MobWorld) -> bool { } true } +/// `Mob.requiresCustomPersistence` and its overrides: a passenger or a leashed +/// mob, a raider in a raid, an enderman carrying a block, a tame nautilus, a +/// sulfur cube holding a block or let out of a bucket. The spawn caps do not +/// count these mobs either (`NaturalSpawner.createState`). +pub fn requires_custom_persistence(m: &Mob) -> bool { + super::riders::keeps_alive(m) + || m.extra.leash.is_some() + || super::raider::state(m).is_some_and(|s| s.raid_id.is_some()) + || m.species() == Species::Enderman && m.extra.carried.is_some() + || super::nautilus::is_nautilus(m.species()) && m.extra.tame + || m.species() == Species::SulfurCube + && (super::sulfur_cube::has_body(m) || m.extra.scratch[3] != 0) +} /// Mob.checkDespawn, before the tick (players are the only despawn /// reference). Returns false when the mob is discarded. fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool { @@ -1216,12 +1229,7 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool { return true; }; let d = m.distance_sqr(p); - // requiresCustomPersistence: raiders in a raid, a tame nautilus, a sulfur - // cube holding a block or let out of a bucket. - let persistent = m.persistent - || super::nautilus::is_nautilus(m.species()) && m.extra.tame - || m.species() == Species::SulfurCube - && (super::sulfur_cube::has_body(m) || m.extra.scratch[3] != 0); + let persistent = m.persistent || requires_custom_persistence(m); // removeWhenFarAway: animals, villagers, golems, patrols and raiders, // zombie villagers being cured and ridden mobs stay. let creature = m.kind.category == "creature"; diff --git a/crates/horizon-client/src/stations.rs b/crates/horizon-client/src/stations.rs index c5ed5d881..14dfedb49 100644 --- a/crates/horizon-client/src/stations.rs +++ b/crates/horizon-client/src/stations.rs @@ -151,8 +151,12 @@ pub struct AnvilResult { pub repair_item_count_cost: u32, pub only_renaming: bool, } +/// `ItemStack.isDamageableItem`: durability, and not `minecraft:unbreakable`. pub fn is_damageable(items: &Items, stack: &ItemStack) -> bool { items.get(&stack.item).is_some_and(|i| i.max_damage > 0) + && stack + .data() + .is_none_or(|d| d.other("minecraft:unbreakable").is_none()) } fn max_damage(items: &Items, stack: &ItemStack) -> i32 { items.get(&stack.item).map_or(0, |i| i.max_damage as i32) diff --git a/crates/horizon-client/src/win_screen.rs b/crates/horizon-client/src/win_screen.rs index 8d2a1c145..9d100c744 100644 --- a/crates/horizon-client/src/win_screen.rs +++ b/crates/horizon-client/src/win_screen.rs @@ -309,11 +309,12 @@ mod tests { /// A resource root with only the credits texts: every glyph is the /// missing one, 6 pixels wide. fn screen(poem: bool, end: &str, credits: &str, post: &str) -> WinScreen { - let root = std::env::temp_dir().join(format!( - "horizon-win-screen-{}-{poem}-{}", - std::process::id(), - end.len() - )); + // One directory per call: tests run in parallel, and two of them with + // the same arguments used to remove each other's files. + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = + std::env::temp_dir().join(format!("horizon-win-screen-{}-{n}", std::process::id())); let texts = root.join("assets/minecraft/texts"); std::fs::create_dir_all(&texts).unwrap(); std::fs::write(texts.join("end.txt"), end).unwrap(); diff --git a/crates/horizon-release-sign/src/lib.rs b/crates/horizon-release-sign/src/lib.rs index ac527f15e..940d9259e 100644 --- a/crates/horizon-release-sign/src/lib.rs +++ b/crates/horizon-release-sign/src/lib.rs @@ -114,26 +114,67 @@ pub fn verify( parse_sums(text) } +/// The channel nightly builds are signed for (nightly-build.yml): their message is +/// `horizon release nightly`, which no release's tag is, so a nightly's signature never passes for a +/// release's, nor a release's for a nightly's. +pub const NIGHTLY: &str = "nightly"; + +/// Whether `tag` is a nightly's: `nightly`, or a past one kept as `nightly-`. +pub fn is_nightly(tag: &str) -> bool { + tag == NIGHTLY || tag.starts_with("nightly-") +} + /// The signed SHA-256 of `asset` in release `tag`, or why the download must be refused. /// `sums` and `signature` are the release's SHA256SUMS and SHA256SUMS.sig, None when it has none. +/// A nightly is never a release: its tags are refused here, whatever their signature +/// ([`nightly_digest`] checks those). pub fn signed_digest( tag: &str, asset: &str, sums: Option<&[u8]>, signature: Option<&str>, keys: &[PublicKey], +) -> Result { + if is_nightly(tag) { + return Err(format!( + "{tag} is a Nightly build: only a launcher with Nightly turned on installs it." + )); + } + digest_in(tag, tag, asset, sums, signature, keys) +} + +/// The signed SHA-256 of `asset` in the nightly build: its checksums must be signed for the +/// [`NIGHTLY`] channel, so a release's signed list (or one replayed from a release) is refused. +pub fn nightly_digest( + asset: &str, + sums: Option<&[u8]>, + signature: Option<&str>, + keys: &[PublicKey], +) -> Result { + digest_in(NIGHTLY, "The Nightly build", asset, sums, signature, keys) +} + +/// `asset`'s SHA-256 in `sums`, once one of `keys` is found to have signed them for `signed_for`; +/// `name` says which build in messages. +fn digest_in( + signed_for: &str, + name: &str, + asset: &str, + sums: Option<&[u8]>, + signature: Option<&str>, + keys: &[PublicKey], ) -> Result { let (Some(sums), Some(signature)) = (sums, signature) else { return Err(format!( - "{tag} is not signed, and Horizon installs only signed releases: pick a newer version." + "{name} is not signed, and Horizon installs only signed releases: pick a newer version." )); }; - verify(tag, sums, signature, keys)? + verify(signed_for, sums, signature, keys)? .into_iter() - .find(|(name, _)| name == asset) + .find(|(n, _)| n == asset) .map(|(_, hex)| hex) .ok_or_else(|| { - format!("{asset} is not in {tag}'s signed checksums, so it was not installed.") + format!("{asset} is not in {name}'s signed checksums, so it was not installed.") }) } @@ -320,6 +361,34 @@ mod tests { assert!(check("x", &sha256_hex(b"windows"), &sha256_hex(b"linux")).is_err()); } + /// The release channel refuses everything signed for the nightly channel, and the other way + /// round: one key signs both, and only the message's channel keeps them apart. + #[test] + fn nightly_and_release_signatures_never_pass_for_each_other() { + let (nightly, sums, signature) = signed(NIGHTLY); + let keys = [nightly.public()]; + let asset = "horizon-windows-x64.zip"; + let digest = nightly_digest(asset, Some(sums.as_bytes()), Some(&signature), &keys); + assert_eq!(digest, Ok(sha256_hex(b"windows"))); + // Served under a release's tag: the message names the nightly channel, not v0.2.81. + for tag in ["v0.2.81", "v2026.10.28", "0.2.81"] { + let as_release = + signed_digest(tag, asset, Some(sums.as_bytes()), Some(&signature), &keys); + assert!(as_release.is_err(), "{tag} took a nightly's signature"); + } + // Asked for by a nightly tag on the release channel: refused before any signature is read. + for tag in [NIGHTLY, "nightly-2026-10-27"] { + let err = signed_digest(tag, asset, Some(sums.as_bytes()), Some(&signature), &keys); + assert!(err.unwrap_err().contains("Nightly build"), "{tag}"); + } + // A release's signed list replayed as tonight's nightly. + let (release, sums, signature) = signed("v0.2.81"); + let keys = [release.public()]; + assert!(nightly_digest(asset, Some(sums.as_bytes()), Some(&signature), &keys).is_err()); + assert!(nightly_digest(asset, Some(sums.as_bytes()), None, &keys).is_err()); + assert!(is_nightly("nightly-2026-10-27") && !is_nightly("v0.2.81")); + } + #[test] fn tampering_is_refused() { let (key, sums, signature) = signed("v2026.10.19"); diff --git a/docs/release-flow.md b/docs/release-flow.md index be0ccbee8..a8d559c91 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -262,8 +262,10 @@ silent (no ping and Discord's @silent flag). Launcher releases post without a pi ### One channel -Since 2026-10-23 there is one channel. Every game release is published as the release, never a pre-release, and -marked GitHub's *latest*: the launcher, `horizon-server update` and `install.sh` take it at once. Because nothing +Since 2026-10-23 there is one release channel, and it is every launcher's default. Since 2026-10-27 (Ludovic's +decision, revising 2026-10-23's) players may also turn on [Nightly](#nightly-channel), an opt-in pre-release built +from reviewed pull requests; nothing about releases changes for anyone who doesn't. Every game release is published +as the release, never a pre-release, and marked GitHub's *latest*: the launcher, `horizon-server update` and `install.sh` take it at once. Because nothing promotes it later, the checks that used to gate a promotion run in the release itself, and the publish job (`tools/release_train.py gate`) publishes nothing unless both pass: @@ -407,6 +409,37 @@ With several game releases a day, the newest launcher release soon falls out of launcher and the website read: both then look the newest launcher tags up one by one (`git/matching-refs/tags/launcher@`, then `launcher-v` if there is none, then `releases/tags/`). +## Nightly channel + +`.github/workflows/nightly-build.yml` publishes an opt-in build once a night (20:17 UTC), for players who want +reviewed fixes before they are released (#2681). It builds the newest `union/-` branch on origin (main +plus the pull requests the contribution gate proved together in one wave) when that union is newer than main's head +and holds something main lacks, otherwise main (`tools/ci/nightly.py pick`). A night with nothing new publishes +nothing. Only proven unions are pushed to origin, so nothing unreviewed ships. + +- **Jobs.** `pick` chooses the commit and writes the notes (the open pull requests it holds beyond main). `build` is + `game-build.yml`, the same builds and smoke test as a release, with the version `nightly-` and + `HORIZON_NIGHTLY=" "` built in, macOS signed ad hoc. `sign` signs SHA256SUMS for the channel + (the message says `horizon release nightly`, so a nightly's signature never passes for a release's). `publish` + checks the signature and the smoke test (not `release-blocker`: a nightly is how blockers get tested), renames the + previous `nightly` pre-release to `nightly-`, publishes the new one as the `nightly` pre-release (never + GitHub's latest), and deletes the `nightly-` pre-releases and tags older than 7 days, nothing else. +- **The signing key never meets the union's code.** Every workflow file and script runs from main's copy (a schedule + runs the default branch; `uses: ./…` takes the caller's commit), never the union's `.github/`. `build` compiles the + union with a read-only token that isn't kept in the checkout and no secrets; the runner's Developer ID keychain + stays locked. `sign` is the only job with the key, and checks out only main's `tools/ci/sign_release.sh`, which + hashes the archives with the runner's `sha256sum` and signs with its OpenSSL: it never runs what `build` made. + `publish` runs main's `horizon-release-sign`, never the archives. +- **Players.** Releases stay the default and the only channel any launcher follows unless its player turns Nightly + on. `release_train.py`, `horizon-server update`, `install.sh` and the launcher's release list skip the tags: none + of them is `vX.Y.Z`. + +```sh +gh workflow run "nightly build" # now; skips when the pick is the commit already published +gh workflow run "nightly build" -f force=true # build and publish anyway +gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing +``` + ## Nightly audit `.github/workflows/nightly.yml` runs every harness from scratch (`tools/parity.py --all`) on main at 03:00 Paris diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 189577fb3..315924ce1 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -158,5 +158,12 @@ "15% off": "-15 %", "Not prepared. Horizon needs the files of your own Minecraft {version}.": "Pas préparé. Horizon a besoin des fichiers de votre propre Minecraft {version}.", "Prepared for Minecraft {version}": "Préparé pour Minecraft {version}", - "Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Préparé pour Minecraft {version}, sans ses sons : le jeu sera muet. Préparez à nouveau pour les copier depuis votre installation de Minecraft." + "Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Préparé pour Minecraft {version}, sans ses sons : le jeu sera muet. Préparez à nouveau pour les copier depuis votre installation de Minecraft.", + "Nightly builds": "Versions Nightly", + "Reviewed fixes not released yet; may break.": "Des correctifs relus, pas encore publiés ; peut casser.", + "Plays in its own Nightly instance: your worlds and the release stay as they are.": "Se joue dans sa propre instance Nightly : vos mondes et la version publiée restent tels quels.", + "Installing the nightly, {percent}%": "Installation de la Nightly, {percent} %", + "The nightly could not be installed: {error}": "La Nightly n'a pas pu être installée : {error}", + "{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.": "{version} dans l'instance Nightly. Choisissez Horizon dans la barre latérale pour jouer la version publiée.", + "No nightly build yet.": "Pas encore de version Nightly." } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index a992ef819..0464b8808 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -158,5 +158,12 @@ "15% off": "скидка 15%", "Not prepared. Horizon needs the files of your own Minecraft {version}.": "Не подготовлено. Horizon нужны файлы вашего собственного Minecraft {version}.", "Prepared for Minecraft {version}": "Подготовлено для Minecraft {version}", - "Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Подготовлено для Minecraft {version}, но без звуков: игра будет беззвучной. Подготовьте заново, чтобы скопировать их из вашей установки Minecraft." + "Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Подготовлено для Minecraft {version}, но без звуков: игра будет беззвучной. Подготовьте заново, чтобы скопировать их из вашей установки Minecraft.", + "Nightly builds": "Ночные сборки", + "Reviewed fixes not released yet; may break.": "Проверенные исправления, ещё не вышедшие в релиз; что-то может сломаться.", + "Plays in its own Nightly instance: your worlds and the release stay as they are.": "Запускается в отдельной сборке Nightly: ваши миры и релиз остаются как есть.", + "Installing the nightly, {percent}%": "Установка ночной сборки, {percent}%", + "The nightly could not be installed: {error}": "Не удалось установить ночную сборку: {error}", + "{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.": "{version} в сборке Nightly. Выберите Horizon на боковой панели, чтобы играть в релиз.", + "No nightly build yet.": "Ночных сборок пока нет." } diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 17840fe90..d7a3992b2 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -90,7 +90,7 @@ fn agent(url: &str) -> ureq::Agent { .build() } -fn get(url: &str) -> Result { +pub(crate) fn get(url: &str) -> Result { let mut request = agent(url).get(url); // CI runners share addresses and hit GitHub's anonymous rate limit; they pass their own token. if let Some(token) = std::env::var("HORIZON_GITHUB_TOKEN") @@ -239,7 +239,7 @@ fn release_list() -> Result { } /// The releases in `list` whose tag passes `keep`, newest first, with the asset named `asset`. -fn parse_releases( +pub(crate) fn parse_releases( list: &serde_json::Value, asset: Option<&str>, keep: fn(&str) -> bool, @@ -475,7 +475,7 @@ fn installed_in(versions: &Path) -> Vec { } /// A small release file (its checksums or their signature), whole; None when it has none. -fn fetch_small(url: &str) -> Result>, String> { +pub(crate) fn fetch_small(url: &str) -> Result>, String> { let response = match get(url) { Ok(response) => response, Err(e) if e.ends_with("answered 404") => return Ok(None), @@ -550,17 +550,28 @@ pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<(), String> if !is_game_tag(tag) { return Err(format!("{tag} is not a Horizon release.")); } + let sha256 = signed_digest(tag, ASSET.unwrap_or_default())?; + install_as(release, tag, &sha256, progress) +} + +/// Downloads `release`'s archive for this platform, checks it against `sha256` (its signed +/// checksum), and unpacks it as the installed version `tag`. +pub(crate) fn install_as( + release: &Release, + tag: &str, + sha256: &str, + progress: impl Fn(f32), +) -> Result<(), String> { let (url, size) = release .asset .clone() .ok_or_else(|| format!("{tag} has no build for this platform."))?; - let sha256 = signed_digest(tag, ASSET.unwrap_or_default())?; let root = versions_dir(); let partial = root.join(format!(".{tag}.partial")); let _ = fs::remove_dir_all(&partial); fs::create_dir_all(&partial).map_err(|e| e.to_string())?; let archive = partial.join("download"); - download(&url, size, &sha256, &archive, &progress)?; + download(&url, size, sha256, &archive, &progress)?; let unpacked = partial.join("unpacked"); unpack(&archive, &url, &unpacked)?; progress(0.97); @@ -2439,6 +2450,10 @@ pub enum Msg { PackDone(Result, String>), /// How many play Horizon now, or none when the server can't be reached (`telemetry`). Playing(Option), + /// The nightly being installed, done this far (`nightly`). + NightlyProgress(f32), + /// A nightly check ended: the version installed (None: already up to date), or why not. + Nightly(Result, String>), } /// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`. @@ -2617,6 +2632,10 @@ pub struct Settings { /// The window's language. Unset follows the system's (`i18n::Language::system`). #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// The player turned Nightly on (`nightly`): the nightly build installs and updates in its own + /// instance. Off by default. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub nightly: bool, } impl Settings { diff --git a/launcher/src/lib.rs b/launcher/src/lib.rs index 43b28f42e..d3c498ec2 100644 --- a/launcher/src/lib.rs +++ b/launcher/src/lib.rs @@ -17,6 +17,7 @@ pub mod instances; pub mod launch; pub mod modpacks; pub mod mojang; +pub mod nightly; pub mod packs; pub mod process; pub mod proxy; diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 1eeb1477c..78cd56bd7 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -52,6 +52,7 @@ use horizon_launcher::account::{self, Accounts, SignIn, SignInState}; use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Updates}; use horizon_launcher::i18n::{self, tr, trf}; use horizon_launcher::instances; +use horizon_launcher::nightly; use horizon_launcher::telemetry; use scrollbar::Scrollbar; use std::borrow::Cow; @@ -168,6 +169,12 @@ struct Launcher { /// nothing can launch, select or prepare it, and its row can't install or remove it again. removing: Option, install_error: Option<(String, String)>, + /// Nightly (`horizon_launcher::nightly`): how far the nightly being installed is, and why the + /// last check or install failed. + nightly_progress: Option, + nightly_error: Option, + /// Play the Nightly instance once its first install ends: the player just turned Nightly on. + nightly_select: bool, confirm_remove: Option, picker_open: bool, /// The top bar's quick switch of who plays (`switch_page.rs`). @@ -373,6 +380,9 @@ impl Launcher { installing: None, removing: None, install_error: None, + nightly_progress: None, + nightly_error: None, + nightly_select: false, confirm_remove: None, picker_open: false, account_menu: false, @@ -533,6 +543,41 @@ impl Launcher { self.install(&release); } + /// With Nightly on, installs the current nightly in the Nightly instance if it is new. + fn update_nightly(&mut self) { + if !self.settings.nightly || self.nightly_progress.is_some() { + return; + } + self.nightly_progress = Some(0.); + self.nightly_error = None; + let (tx, installed) = (self.tx.clone(), self.installed.clone()); + std::thread::spawn(move || { + let result = nightly::latest().and_then(|latest| { + let Some(release) = nightly::update(latest, &installed) else { + return Ok(None); + }; + let progress = |p| { + let _ = tx.send(Msg::NightlyProgress(p)); + }; + nightly::install(&release, progress).map(|_| Some(nightly::version_of(&release))) + }); + let _ = tx.send(Msg::Nightly(result)); + }); + } + + /// Turns Nightly on (install it, then play its instance) or off (play the release again; the + /// Nightly instance and its worlds stay). + fn set_nightly(&mut self, on: bool) { + self.settings.nightly = on; + if on { + self.nightly_select = true; + } else if self.settings.instance.as_deref() == Some(nightly::INSTANCE) { + self.settings.instance = None; + } + self.settings.save(); + self.update_nightly(); + } + fn fetch_releases(&mut self) { self.releases = Load::Loading; let tx = self.tx.clone(); @@ -703,8 +748,32 @@ impl Launcher { if let Some(release) = self.updates.launcher.clone() { self.update_launcher(release); } + self.update_nightly(); } Msg::Updates(Err(e)) => self.update_error = Some(e), + Msg::NightlyProgress(p) => self.nightly_progress = Some(p), + Msg::Nightly(result) => { + self.nightly_progress = None; + self.installed = backend::installed(); + self.reload_instances(); + match result { + Ok(_) + if self.nightly_select + && self.settings.nightly + && self + .instances + .list + .iter() + .any(|i| i.id == nightly::INSTANCE) => + { + self.nightly_select = false; + self.settings.instance = Some(nightly::INSTANCE.into()); + self.settings.save(); + } + Ok(_) => {} + Err(e) => self.nightly_error = Some(e), + } + } Msg::LauncherProgress(p) => { if let SelfUpdate::Downloading(_, f) = &mut self.self_update { *f = p; @@ -2743,6 +2812,7 @@ impl Launcher { Some(toggle.into_any_element()), &t, )) + .child(self.nightly_row(n + 2, cx)) .child(group("Launcher", "monitor")) .child(index_row( n + 3, @@ -2821,6 +2891,55 @@ impl Launcher { .children(self.agents_section(cx)) } + /// Settings' Nightly row: off by default; on, the nightly build installs and updates in its + /// own instance, and the release stays one click away (the Horizon instance). + fn nightly_row(&self, n: usize, cx: &mut Context) -> Div { + let t = self.theme; + let on = self.settings.nightly; + let toggle = + switch("nightly", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.set_nightly(!this.settings.nightly); + cx.notify(); + })); + let what = tr("Reviewed fixes not released yet; may break."); + let status = match (&self.nightly_progress, &self.nightly_error) { + _ if !on => tr("Plays in its own Nightly instance: your worlds and the release stay as they are.") + .to_string(), + (Some(p), _) => trf( + "Installing the nightly, {percent}%", + &[("percent", &format!("{:.0}", p * 100.))], + ), + (None, Some(e)) => trf("The nightly could not be installed: {error}", &[("error", e)]), + (None, None) => match self + .instances + .list + .iter() + .find(|i| i.id == nightly::INSTANCE) + .and_then(|i| i.version.clone()) + { + Some(version) => trf( + "{version} in the Nightly instance. Choose Horizon in the sidebar to play the release.", + &[("version", &version)], + ), + None => tr("No nightly build yet.").to_string(), + }, + }; + index_row( + n, + tr("Nightly builds"), + Some( + div() + .flex() + .flex_col() + .child(what.to_string()) + .child(status) + .into_any_element(), + ), + Some(toggle.into_any_element()), + &t, + ) + } + /// Settings' Game arguments row: the extra arguments the game starts with, kept in /// `settings.json` and appended to the launcher's own (quotes group a word with spaces). fn game_args_row(&self, n: usize, cx: &mut Context) -> Div { diff --git a/launcher/src/nightly.rs b/launcher/src/nightly.rs new file mode 100644 index 000000000..d5f75e8ab --- /dev/null +++ b/launcher/src/nightly.rs @@ -0,0 +1,154 @@ +//! Nightly (docs/release-flow.md "Nightly channel"): an opt-in build of reviewed fixes not released +//! yet, published each night as the `nightly` pre-release. Off by default (`Settings::nightly`). +//! +//! It never touches the release: it installs as its own version, `nightly--`, and plays +//! in its own instance, "Nightly" (`instances/nightly/` in the game's home), so the player's worlds +//! stay where they are and choosing the Horizon instance plays the release again. Its checksums +//! must be signed for the nightly channel (`signed::nightly_digest`); the release channel refuses +//! them. +use crate::backend::{self, Release, ASSET}; +use crate::instances::{self, Instance}; +use horizon_release_sign::NIGHTLY; + +/// The Nightly instance's id (its folder in `instances/`) and name. +pub const INSTANCE: &str = "nightly"; +const NAME: &str = "Nightly"; + +/// The version a nightly installs as: `nightly-2026-10-28-0c02067`, from its title +/// ("Nightly 2026-10-28 · 0c02067"), else its publication date. +pub fn version_of(release: &Release) -> String { + let mut words = release + .name + .strip_prefix("Nightly ") + .unwrap_or_default() + .split(" · "); + let date = words.next().filter(|d| is_date(d)).unwrap_or(&release.date); + let commit = words + .next() + .filter(|c| !c.is_empty() && c.chars().all(|c| c.is_ascii_hexdigit())); + match commit { + Some(commit) => format!("{NIGHTLY}-{date}-{commit}"), + None => format!("{NIGHTLY}-{date}"), + } +} + +fn is_date(text: &str) -> bool { + text.len() == 10 + && text.chars().enumerate().all(|(i, c)| { + if i == 4 || i == 7 { + c == '-' + } else { + c.is_ascii_digit() + } + }) +} + +/// The current nightly build, or None while there is none. +pub fn latest() -> Result, String> { + let url = format!( + "https://api.github.com/repos/{}/releases/tags/{NIGHTLY}", + backend::REPO + ); + let release: serde_json::Value = match backend::get(&url) { + Ok(response) => response.into_json().map_err(|e| e.to_string())?, + Err(e) if e.ends_with("answered 404") => return Ok(None), + Err(e) => return Err(e), + }; + Ok(parse(&release)) +} + +fn parse(release: &serde_json::Value) -> Option { + let list = serde_json::Value::Array(vec![release.clone()]); + backend::parse_releases(&list, ASSET, |tag| tag == NIGHTLY) + .into_iter() + .next() + .filter(|r| r.prerelease) +} + +/// The nightly to install: `latest` when it isn't installed yet. +pub fn update(latest: Option, installed: &[String]) -> Option { + latest.filter(|r| r.asset.is_some() && !installed.contains(&version_of(r))) +} + +/// Installs `release` (the current nightly) as its own version, points the Nightly instance at +/// it (making the instance the first time) and removes older nightly versions. Returns the +/// instance. +pub fn install(release: &Release, progress: impl Fn(f32)) -> Result { + let sha256 = crate::signed::nightly_digest( + ASSET.unwrap_or_default(), + &horizon_release_sign::trusted_keys(), + backend::fetch_small, + )?; + let version = version_of(release); + backend::install_as(release, &version, &sha256, progress)?; + let instance = instance_on(&version)?; + for old in backend::installed() { + if horizon_release_sign::is_nightly(&old) && old != version { + let _ = backend::remove(&old); + } + } + Ok(instance) +} + +/// The Nightly instance, made empty the first time (with the player's options and offline name), +/// playing `version`. +fn instance_on(version: &str) -> Result { + if instances::find(INSTANCE).is_err() { + instances::create(NAME, None)?; + } + instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn release(name: &str, date: &str, asset: bool) -> Release { + Release { + tag: NIGHTLY.into(), + name: name.into(), + body: String::new(), + date: date.into(), + asset: asset.then(|| ("https://example/horizon.zip".into(), 1)), + prerelease: true, + } + } + + #[test] + fn a_nightly_installs_under_its_date_and_commit() { + let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28", true); + assert_eq!(version_of(&r), "nightly-2026-10-28-0c02067"); + let untitled = release("Something else", "2026-10-29", true); + assert_eq!(version_of(&untitled), "nightly-2026-10-29"); + // Never a release version: the release channel's checks and lists leave it out. + assert!(!backend::is_game_tag(&version_of(&r))); + assert!(horizon_release_sign::is_nightly(&version_of(&r))); + } + + #[test] + fn only_a_new_nightly_with_a_build_here_is_an_update() { + let r = release("Nightly 2026-10-28 · 0c02067", "2026-10-28", true); + let name = |r: Option| r.map(|r| r.name); + assert_eq!( + name(update(Some(r.clone()), &["v0.2.80".into()])), + Some(r.name.clone()) + ); + let installed = ["nightly-2026-10-28-0c02067".to_string(), "v0.2.80".into()]; + assert!(update(Some(r), &installed).is_none()); + let no_build = release("Nightly 2026-10-29 · 1a2b3c4", "2026-10-29", false); + assert!(update(Some(no_build), &installed).is_none()); + assert!(update(None, &installed).is_none()); + } + + #[test] + fn only_the_nightly_pre_release_is_read() { + let json = |tag: &str, pre: bool| { + serde_json::json!({"tag_name": tag, "name": "Nightly 2026-10-28 · 0c02067", + "prerelease": pre, "published_at": "2026-10-28T20:40:00Z", + "assets": [{"name": ASSET.unwrap_or("none"), "browser_download_url": "u", "size": 1}]}) + }; + assert!(parse(&json("nightly", true)).is_some()); + assert!(parse(&json("nightly", false)).is_none()); + assert!(parse(&json("v0.2.81", true)).is_none()); + } +} diff --git a/launcher/src/signed.rs b/launcher/src/signed.rs index 4052f625d..4a9426bef 100644 --- a/launcher/src/signed.rs +++ b/launcher/src/signed.rs @@ -30,6 +30,20 @@ pub fn digest( horizon_release_sign::signed_digest(tag, asset, sums.as_deref(), signature.as_deref(), keys) } +/// The signed SHA-256 of `asset` in the current nightly build (the `nightly` pre-release): its +/// checksums must be signed for the nightly channel (`horizon_release_sign::nightly_digest`). +pub fn nightly_digest( + asset: &str, + keys: &[PublicKey], + fetch: impl Fn(&str) -> Result>, String>, +) -> Result { + let tag = horizon_release_sign::NIGHTLY; + let sums = fetch(&asset_url(tag, SUMS))?; + let signature = fetch(&asset_url(tag, SIGNATURE))? + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()); + horizon_release_sign::nightly_digest(asset, sums.as_deref(), signature.as_deref(), keys) +} + #[cfg(test)] mod tests { use super::*; @@ -93,4 +107,41 @@ mod tests { let offline = |_: &str| -> Result>, String> { Err("No connection".into()) }; assert!(digest("v2099.01.01", "horizon.zip", &keys, offline).is_err()); } + + /// The release channel never installs what was signed for the nightly channel, whatever tag it + /// is served under, and the nightly channel never installs a release's signed list. + #[test] + fn the_release_channel_refuses_nightly_signatures() { + let (key, _) = SigningKey::generate().unwrap(); + let keys = [key.public()]; + let sums = sums_file(&[("horizon.zip".into(), sha256_hex(b"game"))]); + let serving = |signed_for: &'static str| { + let signature = key.sign(signed_for, sums.as_bytes()); + let sums = sums.clone(); + move |url: &str| -> Result>, String> { + Ok(Some( + if url.ends_with(".sig") { + signature.clone() + } else { + sums.clone() + } + .into_bytes(), + )) + } + }; + let nightly = serving(horizon_release_sign::NIGHTLY); + assert_eq!( + nightly_digest("horizon.zip", &keys, &nightly), + Ok(sha256_hex(b"game")) + ); + for tag in ["v0.2.81", "nightly", "nightly-2026-10-27"] { + assert!( + digest(tag, "horizon.zip", &keys, &nightly).is_err(), + "{tag}" + ); + } + let release = serving("v0.2.81"); + assert!(digest("v0.2.81", "horizon.zip", &keys, &release).is_ok()); + assert!(nightly_digest("horizon.zip", &keys, &release).is_err()); + } } diff --git a/tests/test_nightly.py b/tests/test_nightly.py new file mode 100644 index 000000000..2bbf8fc98 --- /dev/null +++ b/tests/test_nightly.py @@ -0,0 +1,49 @@ +import datetime as dt +import unittest + +from tools.ci import nightly + +T = dt.datetime(2026, 10, 28, 12, 0, tzinfo=dt.timezone.utc) + + +def ref(name, sha, hours): + return nightly.Ref(name, sha, T + dt.timedelta(hours=hours)) + + +class NightlyTests(unittest.TestCase): + def test_the_newest_union_newer_than_main_is_built(self): + main = ref("main", "m", 0) + unions = [ref("union/a", "a", 1), ref("union/b", "b", 3), ref("union/c", "c", -2)] + self.assertEqual(nightly.choose(main, unions, contains=lambda a, b: False).name, "union/b") + + def test_main_when_no_union_is_newer(self): + main = ref("main", "m", 5) + unions = [ref("union/a", "a", 1), ref("union/b", "b", 3)] + self.assertEqual(nightly.choose(main, unions, contains=lambda a, b: False).name, "main") + self.assertEqual(nightly.choose(main, [], contains=lambda a, b: False).name, "main") + + def test_a_union_main_already_holds_is_skipped(self): + main = ref("main", "m", 0) + unions = [ref("union/landed", "l", 2), ref("union/open", "o", 1)] + landed = lambda a, b: a == "l" # noqa: E731 + self.assertEqual(nightly.choose(main, unions, contains=landed).name, "union/open") + + def test_only_old_dated_nightlies_are_pruned(self): + tags = [{"tagName": "nightly", "isPrerelease": True}, + {"tagName": "nightly-2026-10-20", "isPrerelease": True}, + {"tagName": "nightly-2026-10-21", "isPrerelease": True}, + {"tagName": "nightly-2026-10-19", "isPrerelease": False}, + {"tagName": "v0.2.80", "isPrerelease": False}, + {"tagName": "nightly-2026-10-15-extra", "isPrerelease": True}] + self.assertEqual(nightly.stale(tags, dt.date(2026, 10, 28), 7), ["nightly-2026-10-20"]) + + def test_notes_list_what_is_not_on_main(self): + text = nightly.notes("2026-10-28", ref("union/a", "0c0206714abc", 1), + [{"number": 2476, "title": "Fix pistons", "headRefOid": "x"}]) + self.assertIn("Nightly 2026-10-28 · 0c02067", text) + self.assertIn("- Fix pistons (#2476)", text) + self.assertIn("Everything in it is on main.", nightly.notes("2026-10-28", ref("main", "m" * 40, 0), [])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci/nightly.py b/tools/ci/nightly.py new file mode 100644 index 000000000..57af588c1 --- /dev/null +++ b/tools/ci/nightly.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""The opt-in Nightly channel (.github/workflows/nightly-build.yml, docs/release-flow.md "Nightly"). + + nightly.py pick [--force] which commit tonight's nightly builds: GitHub outputs and nightly-notes.md + nightly.py rotate renames the current `nightly` pre-release to nightly-, frees the tag + nightly.py prune [--days 7] deletes the nightly- pre-releases and tags older than that, nothing else + +A nightly builds the newest union branch on origin (`union/-`: main plus pull requests the contribution +gate proved together), or main when no union is newer than main's head. It is published as the `nightly` +pre-release; the previous one stays a week as nightly-. Runs from main's checkout with `gh`; it reads the +branch it picks with git and never runs anything from it. +""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass + +DATED = re.compile(r"^nightly-(\d{4}-\d{2}-\d{2})$") +TITLE_DATE = re.compile(r"Nightly (\d{4}-\d{2}-\d{2})") + + +@dataclass +class Ref: + name: str + sha: str + date: dt.datetime # committer date + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout.strip() + + +def gh(*args: str, check: bool = True) -> str: + out = subprocess.run(["gh", *args], capture_output=True, text=True) + if check and out.returncode != 0: + sys.exit(f"gh {' '.join(args)}: {out.stderr.strip()}") + return out.stdout.strip() if out.returncode == 0 else "" + + +def is_ancestor(a: str, b: str) -> bool: + return subprocess.run(["git", "merge-base", "--is-ancestor", a, b]).returncode == 0 + + +def choose(main: Ref, unions: list[Ref], contains=is_ancestor) -> Ref: + """The newest union whose head is newer than main's and holds something main lacks; else main.""" + for u in sorted(unions, key=lambda u: u.date, reverse=True): + if u.date <= main.date: + break + if not contains(u.sha, main.sha): + return u + return main + + +def refs(pattern: str) -> list[Ref]: + out = git("for-each-ref", "--format=%(refname:short) %(objectname) %(committerdate:iso-strict)", pattern) + return [Ref(n.removeprefix("origin/"), s, dt.datetime.fromisoformat(d)) + for n, s, d in (line.split(" ", 2) for line in out.splitlines())] + + +def output(**values: str) -> None: + with open(os.environ.get("GITHUB_OUTPUT", "/dev/stdout"), "a") as f: + for k, v in values.items(): + f.write(f"{k}={v}\n") + + +def notes(today: str, chosen: Ref, held: list[dict]) -> str: + lines = [f"Nightly {today} · {chosen.sha[:7]}: built from `{chosen.name}` ({chosen.sha}).", "", + "**Opt-in, not a release.** Fixes the maintainers reviewed but haven't released yet: it may break. " + "Turn it on in the launcher's settings; your release install and your worlds stay as they are. " + "Report bugs with the version the game shows (\"Nightly · \").", ""] + if held: + lines += [f"### Not on main yet ({len(held)})", ""] + lines += [f"- {p['title']} (#{p['number']})" for p in held] + else: + lines += ["Everything in it is on main."] + return "\n".join(lines) + "\n" + + +def pick(force: bool) -> None: + git("fetch", "--quiet", "origin", "+refs/heads/main:refs/remotes/origin/main", + "+refs/heads/union/*:refs/remotes/origin/union/*") + main = refs("refs/remotes/origin/main")[0] + chosen = choose(main, refs("refs/remotes/origin/union/")) + today = dt.datetime.now(dt.timezone.utc).date().isoformat() + previous = git("ls-remote", "--tags", "origin", "refs/tags/nightly").split("\t")[0] or None + build = force or previous != chosen.sha + # The open pull requests this build holds beyond main: their heads are in it and not in main. + prs = json.loads(gh("pr", "list", "--state", "open", "--limit", "400", "--json", "number,title,headRefOid")) + held = [] + for pr in prs: + oid = pr["headRefOid"] + if subprocess.run(["git", "cat-file", "-e", oid], capture_output=True).returncode != 0: + continue # a fork's head that no fetched branch holds + if is_ancestor(oid, chosen.sha) and not is_ancestor(oid, main.sha): + held.append(pr) + held.sort(key=lambda p: p["number"]) + with open("nightly-notes.md", "w") as f: + f.write(notes(today, chosen, held)) + output(build=str(build).lower(), sha=chosen.sha, short=chosen.sha[:7], source=chosen.name, date=today, + version=f"nightly-{today}", nightly=f"{today} {chosen.sha[:7]}") + print(f"{'build' if build else 'skip (unchanged since the last nightly)'}: {chosen.name} {chosen.sha}, " + f"{len(held)} open pull requests beyond main") + + +def rotate() -> None: + """The current `nightly` becomes nightly-, so tonight's can take the tag.""" + current = gh("release", "view", "nightly", "--json", "name", check=False) + if not current: + return + m = TITLE_DATE.search(json.loads(current)["name"]) + dated = f"nightly-{m.group(1)}" if m else f"nightly-{dt.date.today() - dt.timedelta(days=1)}" + if gh("release", "view", dated, "--json", "tagName", check=False): + gh("release", "delete", dated, "--cleanup-tag", "--yes") # a second nightly the same day replaces the first + gh("release", "edit", "nightly", "--tag", dated) + gh("api", "-X", "DELETE", f"repos/{os.environ['GITHUB_REPOSITORY']}/git/refs/tags/nightly", check=False) + print(f"nightly -> {dated}") + + +def stale(tags: list[dict], today: dt.date, days: int) -> list[str]: + """The dated nightly pre-releases older than `days`: never a release, never `nightly` itself.""" + cutoff = today - dt.timedelta(days=days) + return [r["tagName"] for r in tags if r["isPrerelease"] and (m := DATED.match(r["tagName"])) + and dt.date.fromisoformat(m.group(1)) < cutoff] + + +def prune(days: int) -> None: + tags = json.loads(gh("release", "list", "--limit", "200", "--json", "tagName,isPrerelease")) + for tag in stale(tags, dt.datetime.now(dt.timezone.utc).date(), days): + gh("release", "delete", tag, "--cleanup-tag", "--yes") + print(f"deleted {tag}") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("pick").add_argument("--force", action="store_true") + sub.add_parser("rotate") + sub.add_parser("prune").add_argument("--days", type=int, default=7) + args = ap.parse_args() + if args.cmd == "pick": + pick(args.force) + elif args.cmd == "rotate": + rotate() + else: + prune(args.days) + + +if __name__ == "__main__": + main()