Merge upstream/main into fix/shulker-body-yaw

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
This commit is contained in:
levzzzandminimax-m3.1-flash committed 2026-10-27 18:14:07 +05:00
commit a8903cba07
37 files changed
+1856 -412

No files matched your search

+274
View File
@@ -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 "<date> <short sha>" (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
+166
View File
@@ -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/<agent>-<name>: main plus pull requests the contribution gate proved together), or
# main when no union is newer, and publishes it as the `nightly` pre-release. Launchers install it only for players
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
#
# 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
+10 -245
View File
@@ -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]
@@ -173,6 +173,22 @@ fn object_style(o: &serde_json::Map<String, Value>) -> 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)
+6
View File
@@ -42,6 +42,10 @@ pub fn named_color(name: &str) -> Option<u32> {
/// 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 })
}
+26 -3
View File
@@ -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<String>,
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(),
+84 -20
View File
@@ -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<String>,
pages: Vec<Text>,
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<Text> {
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<Text> {
self.gui.atlas.font.split(&Text::literal(page), 114.0)
self.page_lines(&Text::literal(page))
}
fn page_lines(&self, page: &Text) -> Vec<Text> {
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<horizon_client::text::ClickEvent> {
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,
@@ -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<Vec3> = 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;
}
}
}
+67 -1
View File
@@ -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).
+9 -16
View File
@@ -46,6 +46,9 @@ pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// Mace smash knockback on the other players from a guest's turn, waiting
/// for it to end (game/mace.rs).
pub smash_pushes: Vec<super::mace::SmashPush>,
/// 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<bool> {
+87 -4
View File
@@ -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<Vec3>,
/// The player behind the crystal blast under way, whose `explosion`
/// damage source names them (`EndCrystal.hurtServer`).
blast_by: Option<FightWho>,
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<FightWho>,
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);
}
}
}
@@ -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()
+2
View File
@@ -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;
+95 -29
View File
@@ -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<u32>,
team: Option<horizon_client::scoreboard::Team>,
}
/// `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).
@@ -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
+24 -5
View File
@@ -203,6 +203,8 @@ pub(super) struct Remote {
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
death_message: Option<String>,
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
player_type: Option<Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
synced: Vec<Option<ItemStack>>,
@@ -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;
+53 -41
View File
@@ -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();
}
+11 -6
View File
@@ -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 {
+24 -5
View File
@@ -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) {
+1
View File
@@ -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)?;
}
+33 -5
View File
@@ -143,6 +143,9 @@ pub struct WrittenBook {
pub author: String,
pub generation: u8,
pub pages: Vec<String>,
/// 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<Tag>,
}
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<String/Component>`: 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<String> {
match t.get("raw") {
Some(raw) => read_text(raw),
@@ -553,11 +571,14 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
));
}
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<Box<StackData
Some(Tag::List(pages)) => 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")
+8 -5
View File
@@ -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,
},
),
+16 -1
View File
@@ -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<u128>,
@@ -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.
+14 -6
View File
@@ -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";
+4
View File
@@ -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)
+6 -5
View File
@@ -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();
+73 -4
View File
@@ -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-<date>`.
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<String, String> {
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<String, String> {
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<String, String> {
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");
+35 -2
View File
@@ -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/<tag>`).
## 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/<agent>-<name>` 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-<date>` and
`HORIZON_NIGHTLY="<date> <short sha>"` 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-<its date>`, publishes the new one as the `nightly` pre-release (never
GitHub's latest), and deletes the `nightly-<date>` 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
+8 -1
View File
@@ -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."
}
+8 -1
View File
@@ -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.": "Ночных сборок пока нет."
}
+24 -5
View File
@@ -90,7 +90,7 @@ fn agent(url: &str) -> ureq::Agent {
.build()
}
fn get(url: &str) -> Result<ureq::Response, String> {
pub(crate) fn get(url: &str) -> Result<ureq::Response, String> {
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<serde_json::Value, String> {
}
/// 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<String> {
}
/// A small release file (its checksums or their signature), whole; None when it has none.
fn fetch_small(url: &str) -> Result<Option<Vec<u8>>, String> {
pub(crate) fn fetch_small(url: &str) -> Result<Option<Vec<u8>>, 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<Box<crate::modpacks::Imported>, String>),
/// How many play Horizon now, or none when the server can't be reached (`telemetry`).
Playing(Option<u64>),
/// 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<Option<String>, 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<crate::i18n::Language>,
/// 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 {
+1
View File
@@ -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;
+119
View File
@@ -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<String>,
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<f32>,
nightly_error: Option<String>,
/// Play the Nightly instance once its first install ends: the player just turned Nightly on.
nightly_select: bool,
confirm_remove: Option<String>,
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<Self>) -> 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<Self>) -> Div {
+154
View File
@@ -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-<date>-<commit>`, 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<Option<Release>, 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<Release> {
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<Release>, installed: &[String]) -> Option<Release> {
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<Instance, String> {
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<Instance, String> {
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<Release>| 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());
}
}
+51
View File
@@ -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<Option<Vec<u8>>, String>,
) -> Result<String, String> {
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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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());
}
}
+49
View File
@@ -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()
+155
View File
@@ -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-<its date>, frees the tag
nightly.py prune [--days 7] deletes the nightly-<date> pre-releases and tags older than that, nothing else
A nightly builds the newest union branch on origin (`union/<agent>-<name>`: main plus pull requests the contribution
gate proved together), or main when no union is newer than main's head. It is published as the `nightly`
pre-release; the previous one stays a week as nightly-<date>. 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 <date> · <commit>\").", ""]
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-<its date>, 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()