mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-12 17:36:11 -10:00
Merge upstream/main into fix/shulker-body-yaw
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
This commit is contained in:
commit
a8903cba07
37 files changed
+1856
-412
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user