mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 08:07:53 -10:00
Compare commits
51
Commits
No files matched your search
@@ -8,7 +8,8 @@ name: launcher
|
||||
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
|
||||
# commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
@@ -101,6 +102,13 @@ jobs:
|
||||
mac: true
|
||||
asset: horizon-launcher-macos-arm64.zip
|
||||
binary: horizon-launcher
|
||||
mac_arch: arm64
|
||||
- name: macos-x64
|
||||
mac: true
|
||||
target: x86_64-apple-darwin
|
||||
asset: horizon-launcher-macos-x64.zip
|
||||
binary: horizon-launcher
|
||||
mac_arch: x64
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
@@ -113,6 +121,8 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
|
||||
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
|
||||
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
|
||||
# variable, not a secret. Unset, the launcher says sign-in is not configured.
|
||||
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
|
||||
@@ -136,6 +146,8 @@ jobs:
|
||||
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
@@ -156,17 +168,17 @@ jobs:
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$(target/release/${{ matrix.binary }} --version)
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
echo "$out"
|
||||
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
|
||||
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
|
||||
- name: Only system libraries
|
||||
run: |
|
||||
file=target/release/${{ matrix.binary }}
|
||||
file="$BIN/${{ matrix.binary }}"
|
||||
case "$RUNNER_OS" in
|
||||
Windows) python ../tools/check_imports.py "$file" ;;
|
||||
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
@@ -181,16 +193,24 @@ jobs:
|
||||
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
|
||||
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
launcher="$PWD/target/release/${{ matrix.binary }}"
|
||||
launcher="$PWD/$BIN/${{ matrix.binary }}"
|
||||
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
|
||||
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
|
||||
retry "$launcher" versions
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
"$launcher" menu-style classic
|
||||
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
|
||||
"$launcher" menu-style horizon
|
||||
# A new platform's launcher can come out before any stable game release has its build (Intel Macs, #1161):
|
||||
# then there is nothing to install yet.
|
||||
stable_build='import json, sys; sys.exit(not any(r["available_here"] and r["channel"] == "stable" for r in json.load(sys.stdin)))'
|
||||
if ! retry "$launcher" --json versions | ${{ runner.os == 'Windows' && 'python' || 'python3' }} -c "$stable_build"; then
|
||||
echo "::warning::No stable game release has a build for ${{ matrix.name }} yet: install not tested"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
exit 0
|
||||
fi
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
echo "installed client: $client"
|
||||
@@ -221,11 +241,11 @@ jobs:
|
||||
# Developer ID): packaging/macos.
|
||||
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
mkdir -p dist
|
||||
cp target/release/${{ matrix.binary }} dist/
|
||||
cp "$BIN/${{ matrix.binary }}" dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
|
||||
else
|
||||
|
||||
@@ -5,7 +5,8 @@ name: release
|
||||
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
|
||||
# RELEASES_PAUSED=1 (repository variable) stops them.
|
||||
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
|
||||
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub): Apple Silicon, and
|
||||
# Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161), then
|
||||
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
|
||||
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
|
||||
# stable after 6 hours. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
|
||||
@@ -98,6 +99,13 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
|
||||
# programs under Rosetta 2.
|
||||
- name: macos-x64
|
||||
mac: true
|
||||
target: x86_64-apple-darwin
|
||||
dir: horizon-macos-x64
|
||||
asset: horizon-macos-x64.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 }}
|
||||
@@ -106,6 +114,8 @@ jobs:
|
||||
# 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 }}
|
||||
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
|
||||
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -120,6 +130,8 @@ jobs:
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
@@ -128,11 +140,11 @@ jobs:
|
||||
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
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -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; }
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
@@ -147,15 +159,15 @@ jobs:
|
||||
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"
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
|
||||
codesign --verify --strict --verbose=2 "$BIN/$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 }}"
|
||||
file="$BIN/$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; }
|
||||
@@ -166,7 +178,7 @@ jobs:
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
bin=$BIN
|
||||
"$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'
|
||||
@@ -190,7 +202,7 @@ jobs:
|
||||
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"
|
||||
client="$PWD/$BIN/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 \
|
||||
@@ -205,8 +217,9 @@ jobs:
|
||||
EOF
|
||||
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
# One smoke-test.json per release, the Apple Silicon build's; the Intel build must pass its own run too.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
if: runner.environment == 'self-hosted' && !matrix.target
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
@@ -215,7 +228,7 @@ jobs:
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
@@ -299,7 +312,7 @@ jobs:
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh "$TAG" 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
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -352,8 +365,8 @@ jobs:
|
||||
# A beta (pre-release): promote.yml makes it stable and latest after 6 hours (docs/release-flow.md).
|
||||
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
|
||||
--notes-file notes/body.md --prerelease --latest=false 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 \
|
||||
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
|
||||
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip \
|
||||
dist/horizon-server-linux-x86_64.tar.gz dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
|
||||
- name: Dry run, the release that would be published
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
run: |
|
||||
|
||||
@@ -28,9 +28,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon (Intel Macs from the next release) or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or a Mac on macOS 11 or later. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
@@ -42,8 +42,9 @@ log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
@@ -82,12 +82,19 @@ fn main() -> horizon_client::Result<()> {
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
// The inventory menu never reads banner patterns.
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let enchantments = horizon_client::enchant::Enchantments::default();
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
|
||||
@@ -11,8 +11,41 @@ use horizon_client::{
|
||||
items::Items,
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
};
|
||||
use horizon_protocol::registry::Ids;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// The case's maps: (locked, scale) by id, and the next free id.
|
||||
#[derive(Default)]
|
||||
struct CaseMaps {
|
||||
maps: RefCell<HashMap<i32, (bool, u8)>>,
|
||||
next: Cell<i32>,
|
||||
}
|
||||
|
||||
impl horizon_client::menu::Maps for CaseMaps {
|
||||
fn state(&self, id: i32) -> Option<(bool, u8)> {
|
||||
self.maps.borrow().get(&id).copied()
|
||||
}
|
||||
|
||||
/// `MapItemSavedData.scaled` (unlocked, one scale up) or `locked`.
|
||||
fn derive(&self, id: i32, scale: bool) -> Option<i32> {
|
||||
let (_, s) = self.state(id)?;
|
||||
let new = self.next.get();
|
||||
self.next.set(new + 1);
|
||||
let made = if scale {
|
||||
(false, (s + 1).min(4))
|
||||
} else {
|
||||
(true, s)
|
||||
};
|
||||
self.maps.borrow_mut().insert(new, made);
|
||||
Some(new)
|
||||
}
|
||||
}
|
||||
|
||||
fn state(menu: &Menu) -> String {
|
||||
let slots: Vec<String> = (0..menu.slot_count())
|
||||
@@ -24,13 +57,21 @@ fn state(menu: &Menu) -> String {
|
||||
.iter()
|
||||
.map(|(slot, stack)| format!("{slot}:{}", canon_stack(stack.as_ref())))
|
||||
.collect();
|
||||
format!(
|
||||
let line = format!(
|
||||
"{} | {} |{data} | {} | {}",
|
||||
slots.join(" "),
|
||||
canon_stack(menu.carried.as_ref()),
|
||||
menu.drops.join(";"),
|
||||
equipped.join(";")
|
||||
)
|
||||
);
|
||||
if menu.kind() != Kind::Lectern {
|
||||
return line;
|
||||
}
|
||||
// No inventory slots: the inventory Take Book fills.
|
||||
let items: String = (0..36)
|
||||
.map(|i| format!(" {}", canon_stack(menu.player_item(i))))
|
||||
.collect();
|
||||
format!("{line} | inv{items}")
|
||||
}
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
@@ -44,13 +85,28 @@ fn main() -> horizon_client::Result<()> {
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
|
||||
let enchantments = horizon_client::enchant::Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::load(root);
|
||||
let case_maps = CaseMaps::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: Some(&case_maps),
|
||||
};
|
||||
// The mob effects' network ids (BeaconMenu.encodeEffect).
|
||||
let builtin = horizon_protocol::registry::Builtin::find(root)
|
||||
.and_then(|p| horizon_protocol::registry::Builtin::load(&p).ok());
|
||||
let effects = builtin
|
||||
.as_ref()
|
||||
.and_then(|b| b.get("minecraft:mob_effect"))
|
||||
.cloned();
|
||||
let mut out = String::new();
|
||||
for line in input.lines() {
|
||||
let case: Value = serde_json::from_str(line)?;
|
||||
@@ -68,15 +124,41 @@ fn main() -> horizon_client::Result<()> {
|
||||
.unwrap_or(name),
|
||||
)
|
||||
.ok_or("menu kind")?;
|
||||
// [id, scale, locked] each; the level's new maps from 1000.
|
||||
case_maps.next.set(1000);
|
||||
*case_maps.maps.borrow_mut() = case["maps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|m| {
|
||||
let id = m[0].as_i64().unwrap_or(0) as i32;
|
||||
(
|
||||
id,
|
||||
(
|
||||
m[2].as_bool().unwrap_or(false),
|
||||
m[1].as_u64().unwrap_or(0) as u8,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut menu = Menu::new(kind);
|
||||
menu.creative = case["creative"].as_bool().unwrap_or(false);
|
||||
menu.levels = case["levels"].as_i64().unwrap_or(0) as i32;
|
||||
for (i, slot) in case["slots"].as_array().ok_or("slots")?.iter().enumerate() {
|
||||
menu.set_slot(&env, i, parse_stack(slot.as_str().unwrap_or("-"))?);
|
||||
}
|
||||
menu.carried = parse_stack(case["carried"].as_str().unwrap_or("-"))?;
|
||||
for (i, item) in case["inventory"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
menu.set_player_item(i, parse_stack(item.as_str().unwrap_or("-"))?);
|
||||
}
|
||||
menu.settle(&env);
|
||||
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
|
||||
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
|
||||
menu.set_data(i, v.as_i64().unwrap_or(0) as i32);
|
||||
}
|
||||
let mut furnace = furnace_kind.map(|kind| Furnace {
|
||||
kind,
|
||||
@@ -100,6 +182,26 @@ fn main() -> horizon_client::Result<()> {
|
||||
out += &format!("{}\n", state(&menu));
|
||||
continue;
|
||||
}
|
||||
// ServerboundRenameItemPacket: AnvilMenu.setItemName.
|
||||
"RENAME" => {
|
||||
menu.rename(&env, op[3].as_str().unwrap_or_default());
|
||||
out += &format!("{}\n", state(&menu));
|
||||
continue;
|
||||
}
|
||||
// ServerboundSetBeaconPacket: BeaconMenu.updateEffects.
|
||||
"SET_BEACON" => {
|
||||
fn power<'a>(ids: Option<&Ids>, v: &'a Value) -> Option<(i32, &'a str)> {
|
||||
let name = v.as_str()?;
|
||||
Some((ids?.id(name)?, name))
|
||||
}
|
||||
let (primary, secondary) = (
|
||||
power(effects.as_ref(), &op[3]),
|
||||
power(effects.as_ref(), &op[4]),
|
||||
);
|
||||
menu.beacon_update(&env, primary, secondary);
|
||||
out += &format!("{}\n", state(&menu));
|
||||
continue;
|
||||
}
|
||||
// ServerboundContainerSlotStateChangedPacket.
|
||||
"SLOT_STATE" => {
|
||||
if (0..9).contains(&slot) {
|
||||
|
||||
@@ -38,12 +38,19 @@ fn main() -> horizon_client::Result<()> {
|
||||
let recipes = Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::default();
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let enchantments = horizon_client::enchant::Enchantments::default();
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (line, order) in cases.lines().zip(orders.lines()) {
|
||||
|
||||
@@ -239,10 +239,12 @@ fn main() -> horizon_client::Result<()> {
|
||||
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
|
||||
let items = Items::catalog(root)?;
|
||||
let enchantments = Enchantments::load(root);
|
||||
let names = horizon_client::creative::Names::default();
|
||||
let trades = Trades::load(root);
|
||||
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
|
||||
let brewing = horizon_client::brewing::Brewing::load(root, &items);
|
||||
let banners = horizon_client::banner::Banners::load(root);
|
||||
let station_recipes = horizon_client::stations::StationRecipes::default();
|
||||
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
|
||||
.types
|
||||
.into_iter()
|
||||
@@ -378,7 +380,11 @@ fn main() -> horizon_client::Result<()> {
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: Some(&env_trade),
|
||||
maps: None,
|
||||
};
|
||||
trade(&mut out, name, &kv, &mut world, &env, origin)?;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ pub struct Creative {
|
||||
search_index: Vec<SearchEntry>,
|
||||
}
|
||||
/// Display names from the pinned en_us language file.
|
||||
#[derive(Default)]
|
||||
pub struct Names {
|
||||
map: HashMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ pub struct ItemProps {
|
||||
/// `minecraft:brewing_fuel` present.
|
||||
pub brewing_fuel: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Enchantments {
|
||||
pub list: Vec<Enchantment>,
|
||||
index: HashMap<Arc<str>, usize>,
|
||||
|
||||
@@ -21,6 +21,8 @@ pub enum ItemPose {
|
||||
Trident {
|
||||
ticks_used: f32,
|
||||
},
|
||||
/// Riptide's spin (`isAutoSpinAttack`): the item held up like a fork.
|
||||
SpinAttack,
|
||||
Bow {
|
||||
ticks_used: f32,
|
||||
},
|
||||
@@ -67,6 +69,12 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
if matches!(pose, ItemPose::Using) {
|
||||
return m;
|
||||
}
|
||||
if matches!(pose, ItemPose::SpinAttack) {
|
||||
return m
|
||||
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
|
||||
* Mat4::from_rotation_y((invert * 65.0f32).to_radians())
|
||||
* Mat4::from_rotation_z((invert * -85.0f32).to_radians());
|
||||
}
|
||||
let charging = match pose {
|
||||
ItemPose::Bow { ticks_used } => {
|
||||
let power = ticks_used / 20.0;
|
||||
|
||||
@@ -74,6 +74,7 @@ mod lan_entities;
|
||||
mod lan_mobs;
|
||||
mod lan_pvp;
|
||||
mod lan_screen;
|
||||
mod lan_stations;
|
||||
mod lan_trading;
|
||||
mod lan_waypoints;
|
||||
mod leads;
|
||||
@@ -119,6 +120,7 @@ mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_objects;
|
||||
mod remote_projectiles;
|
||||
mod remote_stations;
|
||||
mod render_control;
|
||||
mod render_tick;
|
||||
mod respawn_anchor;
|
||||
@@ -902,6 +904,8 @@ struct WorldRest {
|
||||
behaviours: Behaviours,
|
||||
player_arm: Option<horizon_client::mobs::Part>,
|
||||
player_type: Option<Arc<horizon_client::mobs::MobType>>,
|
||||
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
|
||||
spin_attack: Option<horizon_client::mobs::Part>,
|
||||
stations: horizon_client::stations::StationData,
|
||||
}
|
||||
|
||||
@@ -977,6 +981,9 @@ impl Resources {
|
||||
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
|
||||
&self.rest().player_type
|
||||
}
|
||||
fn spin_attack(&self) -> &Option<horizon_client::mobs::Part> {
|
||||
&self.rest().spin_attack
|
||||
}
|
||||
fn stations(&self) -> &horizon_client::stations::StationData {
|
||||
&self.rest().stations
|
||||
}
|
||||
@@ -2557,14 +2564,23 @@ impl Game<'_> {
|
||||
1029 => Some(("minecraft:block.anvil.destroy", 1.0)),
|
||||
1031 => Some(("minecraft:block.anvil.land", 0.3)),
|
||||
1505 => Some(("minecraft:item.bone_meal.use", 1.0)),
|
||||
1042 => Some(("minecraft:block.grindstone.use", 1.0)),
|
||||
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
|
||||
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
|
||||
1043 => Some(("minecraft:item.book.page_turn", 1.0)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((name, volume)) = sound {
|
||||
let pitch = if id == 1009 {
|
||||
2.6 + (self.world.random.next_float() - self.world.random.next_float())
|
||||
* 0.8
|
||||
} else {
|
||||
1.0
|
||||
let pitch = match id {
|
||||
1009 => {
|
||||
2.6 + (self.world.random.next_float()
|
||||
- self.world.random.next_float())
|
||||
* 0.8
|
||||
}
|
||||
1029 | 1030 | 1042 | 1043 | 1044 => {
|
||||
self.world.random.next_float() * 0.1 + 0.9
|
||||
}
|
||||
_ => 1.0,
|
||||
};
|
||||
self.audio.play(name, c, volume, pitch);
|
||||
}
|
||||
@@ -3917,6 +3933,7 @@ impl Game<'_> {
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let ghast_collisions = self.happy_ghast_player_collisions();
|
||||
let spin_targets = self.spin_targets();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
// A dedicated server has no local player: the world still runs.
|
||||
if self.options.dedicated.is_some() {
|
||||
@@ -3994,6 +4011,7 @@ impl Game<'_> {
|
||||
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
|
||||
// The dimension's FAST_LAVA: the Nether's lava pushes harder.
|
||||
player.fluids.fast_lava = self.dim == Dim::Nether;
|
||||
player.spin_targets = spin_targets;
|
||||
let grounded = player.ground.on_ground;
|
||||
let before = (
|
||||
grounded,
|
||||
@@ -4016,6 +4034,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let events = std::mem::take(&mut player.events);
|
||||
let spin_hit = player.spin_hit.take();
|
||||
let glider_wear = std::mem::take(&mut player.glider_wear);
|
||||
if glider_wear > 0
|
||||
&& !self.creative
|
||||
@@ -4034,6 +4053,7 @@ impl Game<'_> {
|
||||
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
|
||||
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
|
||||
self.tick_avatar(before, riding);
|
||||
self.end_spin_attack(spin_hit);
|
||||
self.record_combat(&events);
|
||||
for event in events {
|
||||
match event {
|
||||
@@ -5433,6 +5453,17 @@ impl Game<'_> {
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == stations::Kind::Merchant);
|
||||
let stonecutter_open = self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == stations::Kind::Stonecutter);
|
||||
let station_kind = self.station.as_ref().map(|s| s.kind);
|
||||
let grindstone_open = station_kind == Some(stations::Kind::Grindstone);
|
||||
let cartography_open = station_kind == Some(stations::Kind::Cartography);
|
||||
let anvil_open = station_kind == Some(stations::Kind::Anvil);
|
||||
let smithing_open = station_kind == Some(stations::Kind::Smithing);
|
||||
let enchanting_open = station_kind == Some(stations::Kind::Enchanting);
|
||||
let beacon_open = station_kind == Some(stations::Kind::Beacon);
|
||||
let (kind, game_of): (Kind, Vec<Option<usize>>) = if self.creative_tab_open() {
|
||||
// ItemPickerMenu: the display slots (clicked by the screen), then the hotbar.
|
||||
let mut v = vec![None; 45];
|
||||
@@ -5459,6 +5490,51 @@ impl Game<'_> {
|
||||
v.push(Some(stations::RESULT));
|
||||
v.extend((9..36).chain(0..9).map(Some));
|
||||
(Kind::Loom, v)
|
||||
} else if stonecutter_open {
|
||||
// StonecutterMenu: the input, the result, then the inventory.
|
||||
let mut v = vec![Some(stations::INPUT), Some(stations::RESULT)];
|
||||
v.extend((9..36).chain(0..9).map(Some));
|
||||
(Kind::Stonecutter, v)
|
||||
} else if grindstone_open || cartography_open {
|
||||
// GrindstoneMenu and CartographyTableMenu: the two inputs, the
|
||||
// result, then the inventory.
|
||||
let mut v = vec![
|
||||
Some(stations::INPUT),
|
||||
Some(stations::INPUT + 1),
|
||||
Some(stations::RESULT),
|
||||
];
|
||||
v.extend((9..36).chain(0..9).map(Some));
|
||||
let kind = if grindstone_open {
|
||||
Kind::Grindstone
|
||||
} else {
|
||||
Kind::Cartography
|
||||
};
|
||||
(kind, v)
|
||||
} else if enchanting_open {
|
||||
// EnchantmentMenu: the item, the lapis, then the inventory.
|
||||
let mut v = vec![Some(stations::INPUT), Some(stations::INPUT + 1)];
|
||||
v.extend((9..36).chain(0..9).map(Some));
|
||||
(Kind::Enchanting, v)
|
||||
} else if beacon_open {
|
||||
// BeaconMenu: the payment, then the inventory.
|
||||
let mut v = vec![Some(stations::INPUT)];
|
||||
v.extend((9..36).chain(0..9).map(Some));
|
||||
(Kind::Beacon, v)
|
||||
} else if anvil_open || smithing_open {
|
||||
// ItemCombinerMenu: the inputs, the result, then the inventory.
|
||||
let inputs = if anvil_open { 2 } else { 3 };
|
||||
let mut v: Vec<Option<usize>> =
|
||||
(0..inputs).map(|i| Some(stations::INPUT + i)).collect();
|
||||
v.push(Some(stations::RESULT));
|
||||
v.extend((9..36).chain(0..9).map(Some));
|
||||
(
|
||||
if anvil_open {
|
||||
Kind::Anvil
|
||||
} else {
|
||||
Kind::Smithing
|
||||
},
|
||||
v,
|
||||
)
|
||||
} else {
|
||||
let (kind, map) = if self.container.is_some() {
|
||||
let size = self.container_size();
|
||||
@@ -5541,6 +5617,10 @@ impl Game<'_> {
|
||||
s.loom.patterns.clone(),
|
||||
)
|
||||
});
|
||||
let stone = self
|
||||
.station
|
||||
.as_ref()
|
||||
.map(|s| (s.selected.map_or(-1, |i| i as i32), s.stone.clone()));
|
||||
let crafter_data = match self.container_kind() {
|
||||
Some(BlockEntity::Crafter(c)) => Some((c.disabled, c.triggered)),
|
||||
_ => None,
|
||||
@@ -5554,6 +5634,18 @@ impl Game<'_> {
|
||||
if let Some((s, mob)) = session.as_mut() {
|
||||
self.merchant_sync_in(s, *mob);
|
||||
}
|
||||
// The cartography table reads and makes the level's maps; a
|
||||
// server's (the client menu's ContainerLevelAccess.NULL) doesn't.
|
||||
let level_maps = (kind == Kind::Cartography && self.remote.is_none()).then(|| {
|
||||
let held: Vec<ItemStack> = before
|
||||
.iter()
|
||||
.flatten()
|
||||
.chain(self.inventory.iter().flatten())
|
||||
.chain(self.carried.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
self.level_maps(&held)
|
||||
});
|
||||
let next_map = if merchant_open { self.peek_map_id() } else { 0 };
|
||||
let tag = |t: &str| self.structure_tag(t);
|
||||
let maps = trading::GameMaps {
|
||||
@@ -5572,10 +5664,21 @@ impl Game<'_> {
|
||||
recipes: self.res.recipes(),
|
||||
brewing: &self.res.stations().brewing,
|
||||
banners: &self.res.stations().banners,
|
||||
station_recipes: &self.res.stations().recipes,
|
||||
enchantments: &self.res.stations().enchantments,
|
||||
names: &self.pack.names,
|
||||
trade: Some(&trade_env),
|
||||
maps: level_maps
|
||||
.as_ref()
|
||||
.map(|m| m as &dyn horizon_client::menu::Maps),
|
||||
};
|
||||
menu.creative = creative;
|
||||
menu.selected = self.selected;
|
||||
menu.levels = self.experience.level;
|
||||
if anvil_open {
|
||||
// The rename box as AnvilScreen.onNameChanged gives it to the menu.
|
||||
menu.anvil.name = self.station.as_ref().map(|s| self.anvil_item_name(s));
|
||||
}
|
||||
for (i, stack) in before.iter().enumerate() {
|
||||
menu.set_slot(&env, i, stack.clone());
|
||||
}
|
||||
@@ -5593,6 +5696,25 @@ impl Game<'_> {
|
||||
menu.loom_patterns = patterns;
|
||||
menu.set_slot(&env, 3, before[3].clone());
|
||||
}
|
||||
if kind == Kind::Stonecutter
|
||||
&& let Some((selected, state)) = stone
|
||||
{
|
||||
// The same: the list and selection between clicks, then the result.
|
||||
menu.data[0] = selected;
|
||||
menu.stone = state;
|
||||
menu.set_slot(&env, 1, before[1].clone());
|
||||
}
|
||||
if kind == Kind::Enchanting
|
||||
&& let Some(s) = self.station.as_ref()
|
||||
{
|
||||
// The offers between clicks (what copying the slots in flagged is not a change).
|
||||
menu.enchant_changed = false;
|
||||
for i in 0..3 {
|
||||
menu.data[i] = s.offers.costs[i];
|
||||
menu.data[7 + i] = s.offers.clues[i].as_ref().map_or(-1, |(_, l)| *l as i32);
|
||||
}
|
||||
menu.data[3] = self.level.xp_seed;
|
||||
}
|
||||
if let Some((disabled, triggered)) = crafter_data {
|
||||
for (i, d) in disabled.iter().enumerate() {
|
||||
menu.data[i] = i32::from(*d);
|
||||
@@ -5647,10 +5769,37 @@ impl Game<'_> {
|
||||
s.loom.selected = usize::try_from(menu.data[0]).ok();
|
||||
s.loom.patterns = std::mem::take(&mut menu.loom_patterns);
|
||||
}
|
||||
for sound in sounds {
|
||||
self.loom_sound(sound);
|
||||
// A server's loom plays its own sounds (the client's menu has no level).
|
||||
for sound in sounds.into_iter().filter(|_| !predicting) {
|
||||
self.station_sound(sound);
|
||||
}
|
||||
self.loom_screen_changed();
|
||||
} else if kind == Kind::Cartography {
|
||||
for sound in sounds.into_iter().filter(|_| !predicting) {
|
||||
self.station_sound(sound);
|
||||
}
|
||||
} else if kind == Kind::Stonecutter {
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
s.selected = usize::try_from(menu.data[0]).ok();
|
||||
s.stone = std::mem::take(&mut menu.stone);
|
||||
s.stonecutter_changed();
|
||||
}
|
||||
for sound in sounds.into_iter().filter(|_| !predicting) {
|
||||
self.station_sound(sound);
|
||||
}
|
||||
}
|
||||
if let Some(amount) = menu.ground.take().filter(|_| !predicting)
|
||||
&& let Some(pos) = self.station.as_ref().map(|s| s.pos)
|
||||
{
|
||||
self.grindstone_taken(pos, amount);
|
||||
}
|
||||
if kind == Kind::Anvil || kind == Kind::Smithing {
|
||||
self.combiner_session_end(&menu, predicting);
|
||||
}
|
||||
if kind == Kind::Enchanting && (menu.enchant_changed || before[0] != menu.slot(0).cloned())
|
||||
{
|
||||
// EnchantmentMenu.slotsChanged: new offers for the item.
|
||||
self.station_changed();
|
||||
}
|
||||
if kind == Kind::CrafterBlock
|
||||
&& let Some(BlockEntity::Crafter(c)) = self
|
||||
@@ -5678,6 +5827,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.apply_equipment();
|
||||
if let Some(maps) = level_maps {
|
||||
self.register_made_maps(maps);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Number keys, F and Q over a slot of the open menu: SWAP with a hotbar
|
||||
@@ -6761,7 +6913,13 @@ impl Game<'_> {
|
||||
/// enchantments.
|
||||
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool) {
|
||||
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
let damage = hand_damage + self.effect_attack_bonus();
|
||||
// Riptide's spin replaces the attack damage attribute.
|
||||
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
|
||||
let damage = if spin > 0.0 {
|
||||
spin
|
||||
} else {
|
||||
hand_damage + self.effect_attack_bonus()
|
||||
};
|
||||
let scale = self.attack_strength_scale();
|
||||
let mut amount = damage * (0.2 + scale * scale * 0.8);
|
||||
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
|
||||
@@ -6889,6 +7047,14 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.attack_mob(index);
|
||||
// Minecraft.startAttack swings after the attack.
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `Player.attack` on a mob: the damage, knockback, sweep, particles and
|
||||
/// sounds.
|
||||
pub(super) fn attack_mob(&mut self, index: usize) {
|
||||
let target_kind = self.mobs[index].kind.id.clone();
|
||||
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
|
||||
let (amount, scale, crit) = self.melee_damage(&target_kind);
|
||||
@@ -6906,7 +7072,6 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.knockback", from, 1.0, 1.0);
|
||||
}
|
||||
self.swing_arm();
|
||||
// LivingEntity.setLastHurtMob (tamed wolves join in).
|
||||
self.combat.last_hurt_mob = Some((self.mobs[index].id, self.world.game_time));
|
||||
let old_health = self.mobs[index].health;
|
||||
@@ -6999,7 +7164,6 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// ServerExplosion: rays from a 16^3 grid lose (resistance + 0.3) * 0.3 per step.
|
||||
/// A mob explosion (`Level.ExplosionInteraction.MOB`: drop decay with the
|
||||
@@ -8499,6 +8663,10 @@ impl Game<'_> {
|
||||
}
|
||||
let shown = self.hands_to_render();
|
||||
let used_hand = self.used_hand();
|
||||
let spinning = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.auto_spin_attack_ticks > 0);
|
||||
for (i, (hand, item)) in Hand::BOTH
|
||||
.into_iter()
|
||||
.zip(self.visible_hand_items())
|
||||
@@ -8618,6 +8786,7 @@ impl Game<'_> {
|
||||
ItemPose::Using
|
||||
}
|
||||
}
|
||||
_ if spinning => ItemPose::SpinAttack,
|
||||
_ => ItemPose::Swing,
|
||||
};
|
||||
let m = to_world
|
||||
@@ -10949,6 +11118,10 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.observe_fullscreen();
|
||||
let now = Instant::now();
|
||||
let frame_cpu = self
|
||||
.options
|
||||
.spikes
|
||||
.and_then(|_| horizon_client::spans::thread_cpu());
|
||||
let elapsed = now.duration_since(self.previous);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
@@ -10978,6 +11151,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
let updated = Instant::now();
|
||||
self.track_toggle_screens();
|
||||
// For the "I'm playing" ping: an atomic, sent on when it changes.
|
||||
horizon_client::heartbeat::set_lan(self.is_published());
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
@@ -11015,15 +11190,16 @@ impl ApplicationHandler for Game<'_> {
|
||||
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
|
||||
let spans = horizon_client::spans::take();
|
||||
if cpu > threshold {
|
||||
let list: Vec<String> = spans
|
||||
.iter()
|
||||
.filter(|(_, ms)| *ms >= 0.5)
|
||||
.map(|(n, ms)| format!("{n} {ms:.1}"))
|
||||
.collect();
|
||||
// The render thread's own CPU time in the frame:
|
||||
// far below the wall time, the frame waited.
|
||||
let worked = horizon_client::spans::thread_cpu()
|
||||
.zip(frame_cpu)
|
||||
.map_or_else(String::new, |(end, start)| {
|
||||
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
|
||||
});
|
||||
log::info!(
|
||||
"Horizon: slow frame {cpu:.1} ms ({} ticks): {}",
|
||||
steps,
|
||||
list.join(", ")
|
||||
"Horizon: slow frame {cpu:.1} ms{worked} ({steps} ticks): {}",
|
||||
horizon_client::spans::describe(&spans)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12070,6 +12246,7 @@ fn load_world_rest(
|
||||
player_type: perspective::player_type(root),
|
||||
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
|
||||
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
|
||||
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12772,9 +12949,26 @@ pub fn run() -> Result<()> {
|
||||
if options.dedicated.is_some() && !dedicated::preflight(&mut options)? {
|
||||
return Ok(());
|
||||
}
|
||||
if options.dedicated.is_some() {
|
||||
// Before any worker starts: on Linux they go behind the server thread.
|
||||
horizon_threads::put_helpers_behind();
|
||||
// Before any worker starts: on Linux they go behind the server thread
|
||||
// and the render thread.
|
||||
horizon_threads::put_helpers_behind();
|
||||
if options.dedicated.is_none() {
|
||||
// The render thread, like the server thread, ahead of the workers on
|
||||
// macOS and Windows. While terrain loads, the worker pools (two with
|
||||
// single player's server thread) and other programs outnumber the
|
||||
// cores, and a frame that gave its core up (in a GPU submit) waited
|
||||
// 10 to 200 ms to get one back.
|
||||
horizon_threads::favour_current();
|
||||
}
|
||||
// Someone is playing: the anonymous ping behind the launcher's "N playing now". Not for a
|
||||
// server, a benchmark or a run that quits by itself.
|
||||
if options.dedicated.is_none()
|
||||
&& options.benchmark.is_none()
|
||||
&& !options.offscreen
|
||||
&& options.frames.is_none()
|
||||
&& options.world_frames.is_none()
|
||||
{
|
||||
horizon_client::heartbeat::start();
|
||||
}
|
||||
let started = Instant::now();
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
|
||||
@@ -15,6 +15,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::render::{DYE, DYE_NAMES},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
/// The beacon screen is 54 px wider than the 176-px menus it shares an origin with.
|
||||
pub(super) const SHIFT: f32 = -27.0;
|
||||
@@ -35,9 +36,6 @@ const PAYMENT: [&str; 5] = [
|
||||
"minecraft:diamond",
|
||||
"minecraft:netherite_ingot",
|
||||
];
|
||||
pub(super) fn is_payment(item: &str) -> bool {
|
||||
PAYMENT.contains(&item)
|
||||
}
|
||||
/// `BeaconMenu.PaymentSlot` and the player inventory, relative to the 176 origin.
|
||||
pub(super) fn slot_position(i: usize) -> Option<(f32, f32)> {
|
||||
Some(match i {
|
||||
@@ -171,7 +169,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
|
||||
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]) + 0.5,
|
||||
@@ -255,9 +253,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn beacon_levels(&self) -> i32 {
|
||||
let Some(pos) = self.station.as_ref().map(|s| s.pos) else {
|
||||
let Some(s) = self.station.as_ref() else {
|
||||
return 0;
|
||||
};
|
||||
if let Some(levels) = s.beacon_levels {
|
||||
return levels;
|
||||
}
|
||||
let pos = s.pos;
|
||||
match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Beacon(b)) => b.levels,
|
||||
_ => 0,
|
||||
@@ -346,8 +348,16 @@ impl Game<'_> {
|
||||
self.screen = super::Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
/// `BeaconMenu.updateEffects`: spend the payment and set the powers.
|
||||
/// `BeaconMenu.updateEffects`: spend the payment and set the powers. On
|
||||
/// a server, `BeaconScreen`'s confirm button sends them
|
||||
/// (`ServerboundSetBeaconPacket`) and the server's menu does it.
|
||||
fn confirm_beacon(&mut self, sel: [Option<usize>; 2]) {
|
||||
if self.remote.is_some() {
|
||||
let id = |e: Option<usize>| e.and_then(|e| self.remote_effect_network_id(EFFECTS[e].0));
|
||||
let (primary, secondary) = (id(sel[0]), id(sel[1]));
|
||||
self.send(S::SetBeacon(play::SetBeacon { primary, secondary }));
|
||||
return;
|
||||
}
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
hud::{HudBatch, rgba},
|
||||
item_data::WrittenBook,
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
@@ -89,6 +90,17 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
pub(super) fn close_book(&mut self) {
|
||||
// A server's lectern: LecternScreen's menu closes (closeContainer).
|
||||
if matches!(
|
||||
self.book,
|
||||
Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
..
|
||||
})
|
||||
) && self.remote.is_some()
|
||||
{
|
||||
self.remote_close_container();
|
||||
}
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
}
|
||||
@@ -319,38 +331,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some(BookScreen::View {
|
||||
pages,
|
||||
page,
|
||||
lectern,
|
||||
}) => {
|
||||
let before = *page;
|
||||
lectern: Some(key), ..
|
||||
}) => lectern_page = Some(*key),
|
||||
Some(BookScreen::View { pages, page, .. }) => {
|
||||
if forward && *page + 1 < pages.len() {
|
||||
*page += 1;
|
||||
} else if !forward && *page > 0 {
|
||||
*page -= 1;
|
||||
}
|
||||
if *page != before
|
||||
&& let Some(pos) = *lectern
|
||||
{
|
||||
lectern_page = Some((pos, *page));
|
||||
}
|
||||
}
|
||||
None => return,
|
||||
}
|
||||
// LecternScreen's page buttons click the menu's (1 back, 2 forward);
|
||||
// the page turns with the server's data slot and level event 1043.
|
||||
if let Some(key) = lectern_page {
|
||||
self.lectern_button(key, if forward { 2 } else { 1 });
|
||||
return;
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:item.book.page_turn", None, 1.0, 1.0);
|
||||
if let Some((pos, page)) = lectern_page {
|
||||
self.set_lectern_page(pos, page as i32);
|
||||
}
|
||||
}
|
||||
/// `LecternBlockEntity.setPage` and `LecternBlock.signalPageChange`.
|
||||
fn set_lectern_page(&mut self, key: (i32, i32, i32), page: i32) {
|
||||
if let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get_mut(&key) {
|
||||
l.page = page;
|
||||
}
|
||||
self.set_lectern_powered(key, true);
|
||||
self.lectern_pulses.push((key, 2));
|
||||
self.container_changed(key);
|
||||
}
|
||||
fn set_lectern_powered(&mut self, key: (i32, i32, i32), powered: bool) {
|
||||
self.set_lectern_property(key, "powered", powered);
|
||||
@@ -380,6 +379,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The 2-tick pulses of turned lecterns.
|
||||
pub(super) fn tick_lecterns(&mut self) {
|
||||
self.follow_lectern();
|
||||
let mut done = Vec::new();
|
||||
for (key, left) in &mut self.lectern_pulses {
|
||||
*left -= 1;
|
||||
@@ -452,6 +452,7 @@ impl Game<'_> {
|
||||
.or_else(|| data.map(|d| d.writable_pages.clone()))
|
||||
.unwrap_or_default();
|
||||
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 {
|
||||
pages,
|
||||
page,
|
||||
@@ -459,24 +460,189 @@ impl Game<'_> {
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `LecternMenu` "Take Book".
|
||||
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
|
||||
let book = match self.world.block_entities.get_mut(&key) {
|
||||
Some(BlockEntity::Lectern(l)) => {
|
||||
l.page = 0;
|
||||
l.book.take()
|
||||
/// A `LecternMenu` button of the open lectern screen: on a server it is
|
||||
/// sent (`handleInventoryButtonClick`); here the lectern's menu takes it
|
||||
/// with the player's inventory, and the screen follows the block entity.
|
||||
fn lectern_button(&mut self, key: (i32, i32, i32), id: i32) {
|
||||
if self.remote.is_some() {
|
||||
if let Some((container, _)) = self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
|
||||
self.send(
|
||||
horizon_protocol::packets::play::Serverbound::ContainerButtonClick(
|
||||
horizon_protocol::packets::play::ContainerButtonClick {
|
||||
container: *container,
|
||||
button: id,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => None,
|
||||
return;
|
||||
}
|
||||
let Some(mut menu) = self.lectern_menu(key) else {
|
||||
return;
|
||||
};
|
||||
if let Some(book) = book
|
||||
&& let Some(rest) = self.give(book)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
for i in 0..36 {
|
||||
menu.set_player_item(i, self.inventory[i].clone());
|
||||
}
|
||||
menu.set_player_item(40, self.offhand.clone());
|
||||
menu.creative = self.creative;
|
||||
menu.selected = self.selected;
|
||||
menu.may_build = self.may_build();
|
||||
if !menu.button(&self.menu_env(), id) {
|
||||
return;
|
||||
}
|
||||
for i in 0..36 {
|
||||
self.inventory[i] = menu.player_item(i).cloned();
|
||||
}
|
||||
self.offhand = menu.player_item(40).cloned();
|
||||
for stack in std::mem::take(&mut menu.dropped) {
|
||||
self.player_drop(stack);
|
||||
}
|
||||
self.lectern_menu_done(key, &menu);
|
||||
self.follow_lectern();
|
||||
}
|
||||
/// The `book` control command: a button of the open book or lectern
|
||||
/// screen (the page arrows, Take Book, Done), then its page.
|
||||
pub(super) fn book_control(
|
||||
&mut self,
|
||||
action: Option<&str>,
|
||||
) -> horizon_client::Result<serde_json::Value> {
|
||||
let lectern = match &self.book {
|
||||
Some(BookScreen::View { lectern, .. }) => *lectern,
|
||||
Some(BookScreen::Edit { .. }) => None,
|
||||
None => return Err("no book is open".into()),
|
||||
};
|
||||
match action {
|
||||
None => {}
|
||||
Some("next") => self.turn_page(true),
|
||||
Some("back") => self.turn_page(false),
|
||||
Some("take") => match lectern {
|
||||
Some(key) => self.lectern_button(key, 3),
|
||||
None => return Err("not a lectern".into()),
|
||||
},
|
||||
Some("done") => self.close_book(),
|
||||
Some(other) => return Err(format!("unknown book button {other}").into()),
|
||||
}
|
||||
Ok(match &self.book {
|
||||
Some(BookScreen::View {
|
||||
pages,
|
||||
page,
|
||||
lectern,
|
||||
}) => serde_json::json!({
|
||||
"page": page, "pages": pages.len(), "lectern": lectern.map(|k| [k.0, k.1, k.2]),
|
||||
}),
|
||||
Some(BookScreen::Edit { pages, page, .. }) => {
|
||||
serde_json::json!({ "page": page, "pages": pages.len(), "lectern": null })
|
||||
}
|
||||
None => serde_json::json!({ "closed": true }),
|
||||
})
|
||||
}
|
||||
/// A server's `minecraft:lectern` menu: LecternScreen, its book and
|
||||
/// page to come (`remote_lectern_book`, `remote_lectern_page`).
|
||||
pub(super) fn remote_open_lectern(&mut self, key: (i32, i32, i32)) {
|
||||
self.open_book(BookScreen::View {
|
||||
pages: Vec::new(),
|
||||
page: 0,
|
||||
lectern: Some(key),
|
||||
});
|
||||
}
|
||||
/// `LecternScreen.bookChanged`: the menu's book slot.
|
||||
pub(super) fn remote_lectern_book(&mut self, book: Option<ItemStack>) {
|
||||
if let Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
pages,
|
||||
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();
|
||||
*page = (*page).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
/// `LecternScreen.pageChanged`: the menu's page data slot.
|
||||
pub(super) fn remote_lectern_page(&mut self, value: i32) {
|
||||
if let Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
pages,
|
||||
page,
|
||||
}) = self.book.as_mut()
|
||||
{
|
||||
self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40);
|
||||
*page = (value.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
/// The server closed its lectern menu; false when none is open.
|
||||
pub(super) fn remote_lectern_closed(&mut self) -> bool {
|
||||
if !matches!(
|
||||
self.book,
|
||||
Some(BookScreen::View {
|
||||
lectern: Some(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = None;
|
||||
r.menu_title = None;
|
||||
}
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
true
|
||||
}
|
||||
/// `LecternMenu` over the lectern at `key` (the menu engine, for the
|
||||
/// local player and LAN guests alike): its book and page.
|
||||
pub(super) fn lectern_menu(&self, key: (i32, i32, i32)) -> Option<Menu> {
|
||||
let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get(&key) else {
|
||||
return None;
|
||||
};
|
||||
let mut menu = Menu::new(Kind::Lectern);
|
||||
menu.set_slot(&self.menu_env(), 0, l.book.clone());
|
||||
menu.data[0] = l.page;
|
||||
Some(menu)
|
||||
}
|
||||
/// The lectern menu's book and page back in the block entity: a new page
|
||||
/// is `LecternBlock.signalPageChange` (the 2-tick pulse, level event
|
||||
/// 1043), a book gone `onBookItemRemove` (`resetBookState`).
|
||||
pub(super) fn lectern_menu_done(&mut self, key: (i32, i32, i32), menu: &Menu) {
|
||||
let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get_mut(&key) else {
|
||||
return;
|
||||
};
|
||||
let (had, before) = (l.book.is_some(), l.page);
|
||||
l.book = menu.slot(0).cloned();
|
||||
l.page = menu.data[0];
|
||||
if had && l.book.is_none() {
|
||||
self.set_lectern_powered(key, false);
|
||||
self.set_lectern_property(key, "has_book", false);
|
||||
self.container_changed(key);
|
||||
} else if menu.data[0] != before {
|
||||
self.set_lectern_powered(key, true);
|
||||
self.lectern_pulses.push((key, 2));
|
||||
self.level_event_at(1043, [key.0, key.1, key.2], 0);
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// The open lectern screen follows its block entity (`LecternScreen`'s
|
||||
/// menu listener): the page, and closing once the book is gone.
|
||||
pub(super) fn follow_lectern(&mut self) {
|
||||
let Some(BookScreen::View {
|
||||
lectern: Some(key),
|
||||
page,
|
||||
pages,
|
||||
}) = self.book.as_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
match self.world.block_entities.get(key) {
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some() => {
|
||||
*page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
_ => self.close_book(),
|
||||
}
|
||||
self.set_lectern_property(key, "has_book", false);
|
||||
self.container_changed(key);
|
||||
self.close_book();
|
||||
}
|
||||
pub(super) fn book_click(&mut self, size: [u32; 2]) {
|
||||
let b = HudBatch::new(size);
|
||||
@@ -537,7 +703,8 @@ impl Game<'_> {
|
||||
self.close_book();
|
||||
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
self.take_lectern_book(key);
|
||||
// LecternScreen's Take Book: the menu's button 3.
|
||||
self.lectern_button(key, 3);
|
||||
}
|
||||
} else if inside((w - 200.0) / 2.0, 196.0, 200.0, 20.0) {
|
||||
click(self);
|
||||
|
||||
@@ -1,70 +1,80 @@
|
||||
//! The cartography table (`CartographyTableMenu`, `CartographyTableScreen`):
|
||||
//! zooming a map out with paper, cloning it with an empty map, locking it
|
||||
//! with a glass pane, the post-processing on take (`MapItem.scaleMap`,
|
||||
//! `lockMap`) and the preview with its error and lock sprites.
|
||||
//! The cartography table (`CartographyTableMenu` on the menu engine,
|
||||
//! `CartographyTableScreen`): zooming a map out with paper, cloning it with
|
||||
//! an empty map, locking it with a glass pane. The engine reads the level's
|
||||
//! maps through `LevelMaps`; the scaled and locked copies a take makes
|
||||
//! (`MapItem.scaleMap`, `lockMap`) are registered after the click. The
|
||||
//! screen draws the preview with its error and lock sprites.
|
||||
use super::{
|
||||
Game,
|
||||
maps::{MapData, map_id},
|
||||
stations::Station,
|
||||
};
|
||||
use horizon_client::{hud::HudBatch, items::ItemStack};
|
||||
use horizon_client::{hud::HudBatch, items::ItemStack, menu::Maps};
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
};
|
||||
|
||||
/// The level's maps as a menu sees them (`ContainerLevelAccess`): the
|
||||
/// states of the maps the click can reach, and the copies it makes.
|
||||
pub(super) struct LevelMaps {
|
||||
states: RefCell<HashMap<i32, (bool, u8)>>,
|
||||
next_id: Cell<i32>,
|
||||
/// (new id, source id, scaled) for each copy made.
|
||||
made: RefCell<Vec<(i32, i32, bool)>>,
|
||||
}
|
||||
|
||||
impl Maps for LevelMaps {
|
||||
fn state(&self, id: i32) -> Option<(bool, u8)> {
|
||||
self.states.borrow().get(&id).copied()
|
||||
}
|
||||
|
||||
fn derive(&self, id: i32, scale: bool) -> Option<i32> {
|
||||
let (_, s) = self.state(id)?;
|
||||
let new = self.next_id.get();
|
||||
self.next_id.set(new + 1);
|
||||
let made = if scale {
|
||||
(false, (s + 1).min(4))
|
||||
} else {
|
||||
(true, s)
|
||||
};
|
||||
self.states.borrow_mut().insert(new, made);
|
||||
self.made.borrow_mut().push((new, id, scale));
|
||||
Some(new)
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `setupResultSlot`.
|
||||
pub(super) fn cartography_result(&self, s: &Station) -> Option<ItemStack> {
|
||||
let (map, extra) = (s.inputs[0].as_ref()?, s.inputs[1].as_ref()?);
|
||||
let data = self.maps.data.get(&map_id(map)?)?;
|
||||
match &*extra.item {
|
||||
"minecraft:paper" if !data.locked && data.scale < 4 => Some(ItemStack {
|
||||
count: 1,
|
||||
..map.clone()
|
||||
}),
|
||||
"minecraft:glass_pane" if !data.locked => Some(ItemStack {
|
||||
count: 1,
|
||||
..map.clone()
|
||||
}),
|
||||
"minecraft:map" => Some(ItemStack {
|
||||
count: 2,
|
||||
..map.clone()
|
||||
}),
|
||||
_ => None,
|
||||
/// The maps of `stacks` (loaded as needed) for a menu over this level.
|
||||
pub(super) fn level_maps<'s>(
|
||||
&mut self,
|
||||
stacks: impl IntoIterator<Item = &'s ItemStack>,
|
||||
) -> LevelMaps {
|
||||
let ids: Vec<i32> = stacks.into_iter().filter_map(map_id).collect();
|
||||
let mut states = HashMap::new();
|
||||
for id in ids {
|
||||
if let Some(m) = self.map(id) {
|
||||
states.insert(id, (m.locked, m.scale));
|
||||
}
|
||||
}
|
||||
LevelMaps {
|
||||
states: RefCell::new(states),
|
||||
next_id: Cell::new(self.peek_map_id()),
|
||||
made: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
/// `onCraftedPostProcess` for the taken result: a scaled or locked copy
|
||||
/// under a new map id. Also spends the inputs' extra item kind check.
|
||||
pub(super) fn cartography_post(&mut self, mut result: ItemStack) -> ItemStack {
|
||||
let extra = self
|
||||
.station
|
||||
.as_ref()
|
||||
.and_then(|s| s.inputs[1].as_ref())
|
||||
.map(|e| e.item.clone());
|
||||
let Some(id) = map_id(&result) else {
|
||||
return result;
|
||||
};
|
||||
let new = match extra.as_deref() {
|
||||
Some("minecraft:paper") => self.map(id).map(|m| m.scaled()),
|
||||
Some("minecraft:glass_pane") => self.map(id).map(|m| m.locked_copy()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(data) = new {
|
||||
let new_id = self.new_map_id(data);
|
||||
result.data_mut().map_id = Some(new_id);
|
||||
|
||||
/// The scaled and locked copies a menu made (`level.setMapData`).
|
||||
pub(super) fn register_made_maps(&mut self, maps: LevelMaps) {
|
||||
for (id, source, scale) in maps.made.into_inner() {
|
||||
let Some(data) = self
|
||||
.map(source)
|
||||
.map(|m| if scale { m.scaled() } else { m.locked_copy() })
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let new = self.new_map_id(data);
|
||||
debug_assert_eq!(new, id, "map ids follow peek_map_id");
|
||||
}
|
||||
result
|
||||
}
|
||||
/// The take sound (once per tick).
|
||||
pub(super) fn cartography_taken(&mut self, pos: [i32; 3]) {
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
"minecraft:ui.cartography_table.take_result",
|
||||
Some(at),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
/// `CartographyTableScreen.extractBackground` after the background.
|
||||
pub(super) fn draw_cartography(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
|
||||
|
||||
@@ -523,13 +523,22 @@ impl Game<'_> {
|
||||
Screen::Inventory if self.recipe_search_focused() => {
|
||||
self.recipe_text(Some(text), false)
|
||||
}
|
||||
// The anvil's rename box.
|
||||
Screen::Inventory
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
|
||||
{
|
||||
self.station_text(Some(text), false)
|
||||
}
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text), false)
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"type needs the chat screen (key t tap), the language search, the creative search or World Options".into(),
|
||||
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -888,7 +897,7 @@ impl Game<'_> {
|
||||
"chunks": self.world.chunks.len(),
|
||||
"mobs": self.mobs.len(),
|
||||
"block_entities": self.world.block_entities.len(),
|
||||
"pending_chunks": self.streaming.pending() + self.pending_uploads.len(),
|
||||
"pending_chunks": self.streaming.pending() + self.pending_uploads.len() + self.remote_backlog(),
|
||||
"generated_chunks": self.streaming.generated_total,
|
||||
"meshed_sections": self.streaming.meshed_total,
|
||||
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
|
||||
|
||||
@@ -2107,15 +2107,22 @@ impl Game<'_> {
|
||||
let from = pl.position;
|
||||
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
|
||||
}
|
||||
// To where the pearl started its last tick (oldPosition).
|
||||
let to = p.previous;
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.position = at;
|
||||
pl.position = to;
|
||||
pl.velocity = Vec3::ZERO;
|
||||
pl.fall_distance = 0.0;
|
||||
pl.teleported = true;
|
||||
}
|
||||
self.previous_position = at;
|
||||
self.previous_position = to;
|
||||
self.hurt_player_by("minecraft:ender_pearl", 5.0);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.teleport", Some(atp), 1.0, 1.0);
|
||||
self.audio.play(
|
||||
"minecraft:entity.player.teleport",
|
||||
Some([to.x, to.y, to.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
(ProjectileKind::SmallFireball, hit) => match hit {
|
||||
|
||||
@@ -10,7 +10,7 @@ use horizon_client::{
|
||||
self, Lan, MAX_PLAYERS, NetConfig,
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::EntityState,
|
||||
entity::{EntityState, States},
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
@@ -69,6 +69,9 @@ pub(super) struct LanHost {
|
||||
/// Guests' commands waiting for the hub to be back in `self.lan`, where
|
||||
/// a command can reach every player (game/lan_actions.rs).
|
||||
pub commands: Vec<super::lan_actions::GuestCommand>,
|
||||
/// The entities the guests track, described again every tick
|
||||
/// (`lan_entities`) over last tick's states.
|
||||
states: lan::entity::States,
|
||||
}
|
||||
|
||||
/// The player who owns an integrated server's world: a guest whose state is
|
||||
@@ -136,8 +139,7 @@ fn baby_index(kind: &str) -> Option<u8> {
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
let mut meta = Vec::new();
|
||||
fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
@@ -280,14 +282,16 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
|
||||
/// off hand, feet, legs, chest, head, body, saddle).
|
||||
fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
fn mob_equipment(
|
||||
m: &Mob,
|
||||
hub: &mut Hub,
|
||||
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
|
||||
) {
|
||||
let e = &m.extra;
|
||||
let mut out = Vec::new();
|
||||
let slots = [
|
||||
(0u8, &e.main_hand),
|
||||
(1, &e.off_hand),
|
||||
@@ -316,7 +320,6 @@ fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::it
|
||||
});
|
||||
out.push((7, hub.stack(&Some(stack))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
@@ -537,6 +540,7 @@ impl Game<'_> {
|
||||
waypoints: Default::default(),
|
||||
waypoint_dead: Default::default(),
|
||||
commands: Vec::new(),
|
||||
states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
Ok(())
|
||||
@@ -740,77 +744,44 @@ impl Game<'_> {
|
||||
equipment,
|
||||
health: p.health.health(),
|
||||
attributes: horizon_client::lan::attributes::syncable(&p.attributes),
|
||||
living_flags: living_flags(self.using),
|
||||
// Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack).
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
bed: self.sleeping,
|
||||
})
|
||||
}
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub) -> Vec<EntityState> {
|
||||
let mut out = Vec::with_capacity(self.mobs.len() + self.dropped.len() + self.orbs.len());
|
||||
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
let kind = m.kind.id.as_str();
|
||||
let meta = mob_meta(m, hub);
|
||||
let equipment = mob_equipment(m, hub);
|
||||
let attributes = self.mob_attributes(m);
|
||||
let (yaw, pitch) = (m.yaw, m.head_pitch);
|
||||
out.push(EntityState {
|
||||
id: m.id as i32,
|
||||
uuid: entity_uuid(m.id),
|
||||
kind: kind.to_owned(),
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
yaw,
|
||||
pitch,
|
||||
head_yaw: m.head_yaw,
|
||||
on_ground: m.on_ground,
|
||||
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
|
||||
data: 0,
|
||||
meta,
|
||||
equipment,
|
||||
attributes,
|
||||
precise: false,
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
mob_meta(m, hub, &mut e.meta);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
}
|
||||
for d in &self.dropped {
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
out.push(EntityState {
|
||||
id: d.id as i32,
|
||||
uuid: entity_uuid(d.id),
|
||||
kind: "minecraft:item".to_owned(),
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
head_yaw: 0.0,
|
||||
on_ground: d.on_ground,
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
data: 0,
|
||||
meta: vec![(8, serializer::ITEM_STACK, Value::Item(stack))],
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
});
|
||||
let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item");
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
|
||||
}
|
||||
for o in &self.orbs {
|
||||
out.push(EntityState {
|
||||
id: o.id as i32,
|
||||
uuid: entity_uuid(o.id),
|
||||
kind: "minecraft:experience_orb".to_owned(),
|
||||
position: [o.position.x, o.position.y, o.position.z],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
head_yaw: 0.0,
|
||||
on_ground: o.on_ground,
|
||||
velocity: [o.velocity.x, o.velocity.y, o.velocity.z],
|
||||
data: 0,
|
||||
// ExperienceOrb.DATA_VALUE.
|
||||
meta: vec![(8, serializer::INT, Value::Int(o.value as i32))],
|
||||
equipment: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
precise: false,
|
||||
});
|
||||
let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb");
|
||||
e.position = [o.position.x, o.position.y, o.position.z];
|
||||
e.on_ground = o.on_ground;
|
||||
e.velocity = [o.velocity.x, o.velocity.y, o.velocity.z];
|
||||
// ExperienceOrb.DATA_VALUE.
|
||||
e.meta
|
||||
.push((8, serializer::INT, Value::Int(o.value as i32)));
|
||||
}
|
||||
self.lan_object_states(host_entity, hub, &mut out);
|
||||
out
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
}
|
||||
|
||||
/// Run the hub for this tick and apply what the guests did.
|
||||
@@ -832,21 +803,23 @@ impl Game<'_> {
|
||||
// A dedicated server runs the hub without a host player.
|
||||
if state.is_some() || host.hub.config.dedicated {
|
||||
let has_guests = !host.hub.guests.is_empty();
|
||||
let entities = if has_guests {
|
||||
let mut states = std::mem::take(&mut host.states);
|
||||
if has_guests {
|
||||
let host_entity = state.as_ref().map(|s| s.entity_id);
|
||||
self.lan_assign_ids();
|
||||
self.lan_riding(host_entity, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &mut host.hub)
|
||||
self.lan_entities(host_entity, &mut host.hub, &mut states);
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
states.clear();
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state.as_ref(),
|
||||
entities: &entities,
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
@@ -887,7 +860,7 @@ impl Game<'_> {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), &entities, spawn);
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
{
|
||||
@@ -952,6 +925,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
host.states = states;
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.tick_guests(&mut host);
|
||||
@@ -1011,15 +985,15 @@ impl Game<'_> {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
self.load_guest_records(host, conn);
|
||||
let theirs;
|
||||
let mut theirs = States::default();
|
||||
let entities = if elsewhere {
|
||||
if let Err(e) = self.enter_level(Some(host), level) {
|
||||
log::error!("could not open {}: {e}", level.id());
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
theirs = self.lan_entities(None, &mut host.hub);
|
||||
&theirs[..]
|
||||
self.lan_entities(None, &mut host.hub, &mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
entities
|
||||
};
|
||||
|
||||
@@ -53,6 +53,10 @@ pub(super) struct GuestPlay {
|
||||
pub(super) merchant: Option<super::lan_trading::GuestMerchant>,
|
||||
/// The inventory menu and the crafting table the guest crafts in.
|
||||
pub(super) crafting: super::lan_crafting::GuestCrafting,
|
||||
/// The workstation menu the guest has open (game/lan_stations.rs).
|
||||
pub(super) station: Option<super::lan_stations::GuestStation>,
|
||||
/// The lectern the guest reads (game/lan_stations.rs).
|
||||
pub(super) lectern: Option<super::lan_stations::GuestLectern>,
|
||||
/// What the guest's body was given for its effects and enchantments.
|
||||
potions: super::potions::BodyPotions,
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
@@ -476,7 +480,13 @@ impl Game<'_> {
|
||||
self.container = container;
|
||||
self.screen = screen;
|
||||
self.sign_edit = host_sign;
|
||||
self.book = host_book;
|
||||
// A lectern the guest's use opened (LecternBlock.openScreen).
|
||||
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
|
||||
Some(super::books::BookScreen::View {
|
||||
lectern: Some(key), ..
|
||||
}) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
self.command_block_screen = host_command_block;
|
||||
self.mount_menu = host_mount_menu;
|
||||
self.keys = host_keys;
|
||||
@@ -532,12 +542,14 @@ impl Game<'_> {
|
||||
g.health = m.health.health();
|
||||
g.food = m.health.food.food;
|
||||
g.saturation = m.health.food.saturation;
|
||||
// A teleport (commands) moves the guest.
|
||||
// A teleport (commands, a pearl) moves the guest; a pearl's is
|
||||
// sent even to where they stand (ServerPlayer.teleport).
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
m.sprinting = false;
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
g.motion = Some(Box::new(m));
|
||||
if moved > 0.0001 || turned {
|
||||
if moved > 0.0001 || turned || teleported {
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
}
|
||||
@@ -571,6 +583,9 @@ impl Game<'_> {
|
||||
if let Some(station) = opened_station {
|
||||
self.lan_station_opened(host, conn, station);
|
||||
}
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
if opened_table {
|
||||
self.lan_open_crafting(host, conn);
|
||||
}
|
||||
@@ -637,6 +652,13 @@ impl Game<'_> {
|
||||
_ => ("minecraft:furnace", "container.furnace"),
|
||||
};
|
||||
(menu, title, Slots::Block(pos))
|
||||
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
|
||||
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
|
||||
(
|
||||
"minecraft:crafter_3x3",
|
||||
"container.crafter",
|
||||
Slots::Block(pos),
|
||||
)
|
||||
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
|
||||
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
|
||||
(
|
||||
@@ -679,6 +701,7 @@ impl Game<'_> {
|
||||
};
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
host.hub.open_container(
|
||||
conn,
|
||||
menu,
|
||||
@@ -913,6 +936,16 @@ impl Game<'_> {
|
||||
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_machine_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_station_click(host, conn, &p) => {}
|
||||
S::ContainerClick(p) if self.lan_lectern_click(host, conn, &p) => {}
|
||||
S::ContainerButtonClick(p) => {
|
||||
self.lan_station_button(host, conn, p.container, p.button);
|
||||
}
|
||||
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
|
||||
S::ContainerSlotStateChanged(p) => {
|
||||
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
|
||||
}
|
||||
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
|
||||
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
|
||||
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
@@ -920,7 +953,10 @@ impl Game<'_> {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
}
|
||||
}
|
||||
S::ContainerClose(p) if self.lan_crafting_closed(host, conn, Some(p.container)) => {
|
||||
S::ContainerClose(p)
|
||||
if self.lan_crafting_closed(host, conn, Some(p.container))
|
||||
|| self.lan_station_closed(host, conn, Some(p.container)) =>
|
||||
{
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if let Some(c) = g.carried.take() {
|
||||
self.drop_guest_items(host, gi, vec![c]);
|
||||
@@ -1438,6 +1474,7 @@ impl Game<'_> {
|
||||
/// Per tick, for every guest: dig, use items, pick up drops, die.
|
||||
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
|
||||
self.lan_merchant_tick(host);
|
||||
self.lan_stations_tick(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
|
||||
@@ -83,17 +83,16 @@ impl Game<'_> {
|
||||
/// `LivingEntity.removeFrost`), what finalizeSpawn set, the baby speed
|
||||
/// modifier, its effects' modifiers, its equipment's, and what
|
||||
/// `/attribute` changed.
|
||||
pub(super) fn mob_attributes(&self, m: &Mob) -> Vec<AttributeSnapshot> {
|
||||
pub(super) fn mob_attributes(&self, m: &Mob, out: &mut Vec<AttributeSnapshot>) {
|
||||
let kind = m.kind.id.as_str();
|
||||
let mut out = Vec::new();
|
||||
if let Some(a) = self.synced_attribute(kind, "minecraft:movement_speed") {
|
||||
instance(&mut out, a, finalize::base_value(m, Attr::MovementSpeed));
|
||||
instance(out, a, finalize::base_value(m, Attr::MovementSpeed));
|
||||
}
|
||||
if let Some(s) = m.extra.attributes.as_deref() {
|
||||
for (attr, v) in &s.bases {
|
||||
if let Some(a) = self.synced_attribute(kind, &format!("minecraft:{}", attr.name()))
|
||||
{
|
||||
instance(&mut out, a, *v).base = *v;
|
||||
instance(out, a, *v).base = *v;
|
||||
}
|
||||
}
|
||||
for x in &s.modifiers {
|
||||
@@ -101,7 +100,7 @@ impl Game<'_> {
|
||||
self.synced_attribute(kind, &format!("minecraft:{}", x.attr.name()))
|
||||
{
|
||||
add_modifier(
|
||||
&mut out,
|
||||
out,
|
||||
a,
|
||||
finalize::base_value(m, x.attr),
|
||||
AttributeModifier {
|
||||
@@ -122,7 +121,7 @@ impl Game<'_> {
|
||||
finalize::transient(m, Attr::MovementSpeed),
|
||||
) {
|
||||
add_modifier(
|
||||
&mut out,
|
||||
out,
|
||||
a,
|
||||
finalize::base_value(m, Attr::MovementSpeed),
|
||||
AttributeModifier {
|
||||
@@ -150,7 +149,7 @@ impl Game<'_> {
|
||||
};
|
||||
let start = base(attribute).unwrap_or_else(|| self.attribute_default(kind, attribute));
|
||||
add_modifier(
|
||||
&mut out,
|
||||
out,
|
||||
a,
|
||||
start,
|
||||
AttributeModifier {
|
||||
@@ -172,10 +171,9 @@ impl Game<'_> {
|
||||
("chest", e.chest.as_ref()),
|
||||
("head", e.head.as_ref()),
|
||||
],
|
||||
&mut out,
|
||||
out,
|
||||
);
|
||||
self.generic_attributes_of(Target::Mob(m.id), &mut out);
|
||||
out
|
||||
self.generic_attributes_of(Target::Mob(m.id), out);
|
||||
}
|
||||
|
||||
/// An armor stand's synced instances: movement speed (its first
|
||||
|
||||
@@ -55,6 +55,7 @@ fn load_player(menu: &mut Menu, g: &Guest) {
|
||||
menu.set_player_item(OFFHAND, g.offhand.clone());
|
||||
menu.selected = g.selected;
|
||||
menu.creative = g.creative();
|
||||
menu.levels = g.xp_level;
|
||||
menu.carried = g.carried.clone();
|
||||
}
|
||||
|
||||
@@ -83,27 +84,47 @@ fn place_back(menu: &mut Menu, env: &Env, stacks: Vec<ItemStack>) {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn menu_env(&self) -> Env<'_> {
|
||||
pub(super) fn menu_env(&self) -> Env<'_> {
|
||||
Env {
|
||||
items: self.res.items(),
|
||||
recipes: self.res.recipes(),
|
||||
brewing: &self.res.stations().brewing,
|
||||
banners: &self.res.stations().banners,
|
||||
station_recipes: &self.res.stations().recipes,
|
||||
enchantments: &self.res.stations().enchantments,
|
||||
names: &self.pack.names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `op` on the guest's menu with their inventory copied in and back.
|
||||
fn guest_menu_op<R>(
|
||||
pub(super) fn guest_menu_op<R>(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
gi: usize,
|
||||
menu: &mut Menu,
|
||||
op: impl FnOnce(&mut Menu, &Env) -> R,
|
||||
) -> R {
|
||||
let env = self.menu_env();
|
||||
// The cartography table reads and makes the host level's maps.
|
||||
let maps = (menu.kind() == Kind::Cartography).then(|| {
|
||||
let g = &host.hub.guests[gi];
|
||||
let held: Vec<ItemStack> = (0..menu.slot_count())
|
||||
.filter_map(|s| menu.slot(s).cloned())
|
||||
.chain(g.inventory.iter().flatten().cloned())
|
||||
.chain(g.carried.clone())
|
||||
.collect();
|
||||
self.level_maps(&held)
|
||||
});
|
||||
let env = Env {
|
||||
maps: maps.as_ref().map(|m| m as &dyn horizon_client::menu::Maps),
|
||||
..self.menu_env()
|
||||
};
|
||||
load_player(menu, &host.hub.guests[gi]);
|
||||
let result = op(menu, &env);
|
||||
if let Some(maps) = maps {
|
||||
self.register_made_maps(maps);
|
||||
}
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
menu.drops.clear();
|
||||
@@ -201,8 +222,14 @@ impl Game<'_> {
|
||||
(Kind::Furnace(recipe), f.items.to_vec())
|
||||
}
|
||||
Some(BlockEntity::Brewing(b)) => (Kind::Brewing, b.items.to_vec()),
|
||||
Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()),
|
||||
_ => return false,
|
||||
};
|
||||
// CrafterBlockEntity.containerData: the disabled slots, then triggered.
|
||||
let crafter_data = match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Crafter(c)) => Some((c.disabled, c.triggered)),
|
||||
_ => None,
|
||||
};
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return true;
|
||||
}
|
||||
@@ -219,6 +246,13 @@ impl Game<'_> {
|
||||
for (k, s) in items.iter().enumerate() {
|
||||
menu.set_slot(env, k, s.clone());
|
||||
}
|
||||
// After the slots: setting a disabled slot (CrafterBlockEntity.setItem) enables it.
|
||||
if let Some((disabled, triggered)) = crafter_data {
|
||||
for (k, d) in disabled.iter().enumerate() {
|
||||
menu.data[k] = i32::from(*d);
|
||||
}
|
||||
menu.data[9] = i32::from(triggered);
|
||||
}
|
||||
let before: Vec<_> = (0..menu.slot_count())
|
||||
.map(|s| menu.slot(s).cloned())
|
||||
.collect();
|
||||
@@ -241,6 +275,13 @@ impl Game<'_> {
|
||||
b.items[k] = menu.slot(k).cloned();
|
||||
}
|
||||
}
|
||||
// CrafterBlockEntity.setItem: filling a disabled slot enables it.
|
||||
Some(BlockEntity::Crafter(c)) => {
|
||||
for k in 0..9 {
|
||||
c.items[k] = menu.slot(k).cloned();
|
||||
c.disabled[k] = menu.data[k] == 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
@@ -248,6 +289,39 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleContainerSlotStateChanged`: a
|
||||
/// guest's crafter slot toggle (`CrafterBlockEntity.setSlotState`) in
|
||||
/// their open crafter window.
|
||||
pub(super) fn lan_crafter_slot_state(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: i32,
|
||||
slot: i32,
|
||||
enabled: bool,
|
||||
) {
|
||||
let Some(pos) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.filter(|g| !g.spectator())
|
||||
.and_then(|g| g.menu.as_ref())
|
||||
.filter(|m| window != 0 && m.window == window)
|
||||
.and_then(|m| match m.slots {
|
||||
Slots::Block(pos) => Some(pos),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let (Some(BlockEntity::Crafter(c)), Ok(slot)) = (
|
||||
self.world.block_entities.get_mut(&pos),
|
||||
usize::try_from(slot),
|
||||
) {
|
||||
c.set_slot_state(slot, enabled);
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlaceRecipe: the guest's recipe
|
||||
/// book places a known recipe in their inventory grid or crafting table
|
||||
/// (`ServerPlaceRecipe`), and answers with a ghost recipe when the items
|
||||
@@ -331,6 +405,7 @@ impl Game<'_> {
|
||||
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
|
||||
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
self.guest_container_closed(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.crafting", &[]);
|
||||
@@ -430,22 +505,35 @@ impl Game<'_> {
|
||||
None => return false,
|
||||
}
|
||||
};
|
||||
if grid.is_empty() {
|
||||
return true;
|
||||
if !grid.is_empty() {
|
||||
self.lan_place_back(host, gi, grid);
|
||||
}
|
||||
let mut menu = Menu::new(Kind::Inventory);
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| place_back(menu, env, grid));
|
||||
true
|
||||
}
|
||||
|
||||
/// A guest left: what their crafting grids held goes back into their
|
||||
/// `Inventory.placeItemBackInInventory` of `stacks` for guest `gi`, what
|
||||
/// doesn't fit dropped (`clearContainer`).
|
||||
pub(super) fn lan_place_back(&mut self, host: &mut LanHost, gi: usize, stacks: Vec<ItemStack>) {
|
||||
let mut menu = Menu::new(Kind::Inventory);
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
place_back(menu, env, stacks)
|
||||
});
|
||||
}
|
||||
|
||||
/// A guest left: what their crafting grids and workstation held goes back into their
|
||||
/// inventory before it is saved (dropped where they stood if full).
|
||||
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
|
||||
let table = host.play.remove(&g.conn).and_then(|p| p.crafting.table);
|
||||
let play = host.play.remove(&g.conn);
|
||||
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
|
||||
g.craft_result = None;
|
||||
if let Some((_, _, menu)) = table {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
if let Some(play) = play {
|
||||
if let Some((_, _, menu)) = play.crafting.table {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
}
|
||||
// A workstation's inputs too.
|
||||
if let Some(station) = &play.station {
|
||||
grid.extend(super::lan_stations::station_inputs(station));
|
||||
}
|
||||
}
|
||||
if grid.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! synched data that differs from the defaults), and who rides what.
|
||||
use super::Game;
|
||||
use horizon_client::lan::{
|
||||
entity::{EntityState, Riding},
|
||||
entity::{EntityState, Riding, States},
|
||||
hub::Hub,
|
||||
};
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
@@ -107,7 +107,7 @@ impl Game<'_> {
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut Vec<EntityState>,
|
||||
out: &mut States,
|
||||
) {
|
||||
for b in &self.boats {
|
||||
let p = b.position;
|
||||
@@ -268,12 +268,7 @@ impl Game<'_> {
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
/// end crystals, as guests track them.
|
||||
fn lan_projectile_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut Vec<EntityState>,
|
||||
) {
|
||||
fn lan_projectile_states(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
let owner = |who: Option<horizon_client::mobs::Who>| match who {
|
||||
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
|
||||
Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0),
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
//! Workstation menus for LAN guests: the screen a guest's use opens runs on
|
||||
//! the host's menu engine (`horizon_client::menu`), the one single player
|
||||
//! uses, with vanilla's packets: OpenScreen with the station's `MenuType`,
|
||||
//! container clicks, `ServerboundContainerButtonClickPacket`, the content and
|
||||
//! data slot sync after each (`broadcastChanges`), and the inputs back into
|
||||
//! the inventory when the menu closes (`AbstractContainerMenu.clearContainer`).
|
||||
use super::{
|
||||
Game,
|
||||
lan::LanHost,
|
||||
lan_crafting::input,
|
||||
stations::{Kind as StationKind, Station},
|
||||
};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// A guest's open workstation menu.
|
||||
pub(super) struct GuestStation {
|
||||
window: i32,
|
||||
state: i32,
|
||||
kind: StationKind,
|
||||
pos: [i32; 3],
|
||||
menu: Box<Menu>,
|
||||
/// The data slots the client was last sent.
|
||||
sent_data: Vec<i32>,
|
||||
/// The game time of the menu's last sound (`lastSoundTime`).
|
||||
last_sound: u64,
|
||||
}
|
||||
|
||||
/// A guest's open lectern: `LecternMenu` over the block entity, made on the
|
||||
/// menu engine for each click (`Game::lectern_menu`).
|
||||
pub(super) struct GuestLectern {
|
||||
window: i32,
|
||||
state: i32,
|
||||
key: (i32, i32, i32),
|
||||
/// The book and page the client was last sent (`broadcastChanges`).
|
||||
sent: Option<(Option<ItemStack>, i32)>,
|
||||
}
|
||||
|
||||
/// The `MenuType`, the engine's menu and the input slots
|
||||
/// (`clearContainer`'s container) of a station a guest can use.
|
||||
fn menu_of(kind: StationKind) -> Option<(&'static str, Kind, std::ops::Range<usize>)> {
|
||||
Some(match kind {
|
||||
StationKind::Loom => ("minecraft:loom", Kind::Loom, 0..3),
|
||||
StationKind::Stonecutter => ("minecraft:stonecutter", Kind::Stonecutter, 0..1),
|
||||
StationKind::Grindstone => ("minecraft:grindstone", Kind::Grindstone, 0..2),
|
||||
StationKind::Anvil => ("minecraft:anvil", Kind::Anvil, 0..2),
|
||||
StationKind::Smithing => ("minecraft:smithing", Kind::Smithing, 0..3),
|
||||
StationKind::Enchanting => ("minecraft:enchantment", Kind::Enchanting, 0..2),
|
||||
StationKind::Beacon => ("minecraft:beacon", Kind::Beacon, 0..1),
|
||||
StationKind::Cartography => ("minecraft:cartography_table", Kind::Cartography, 0..2),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A workstation a guest's use opened (`ServerPlayer.openMenu`); false
|
||||
/// when guests can't use that one yet.
|
||||
pub(super) fn lan_open_station(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
station: &Station,
|
||||
) -> bool {
|
||||
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
|
||||
return false;
|
||||
};
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
// The container it replaces loses its opener.
|
||||
self.guest_container_closed(host, conn);
|
||||
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
|
||||
let Some(window) = host.hub.open_window(conn, menu_type, title) else {
|
||||
return true;
|
||||
};
|
||||
let mut menu = Box::new(Menu::new(kind));
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
&& kind == Kind::Enchanting
|
||||
{
|
||||
// Player.enchantmentSeed, made when first needed.
|
||||
if g.xp_seed == 0 {
|
||||
g.xp_seed = self.world.random.next_int();
|
||||
}
|
||||
menu.data[3] = g.xp_seed;
|
||||
}
|
||||
if kind == Kind::Beacon {
|
||||
menu.data = self.beacon_data(host, station.pos).to_vec();
|
||||
}
|
||||
host.play.entry(conn).or_default().station = Some(GuestStation {
|
||||
window,
|
||||
state: 0,
|
||||
kind: station.kind,
|
||||
pos: station.pos,
|
||||
menu,
|
||||
sent_data: Vec::new(),
|
||||
last_sound: 0,
|
||||
});
|
||||
self.lan_send_station(host, conn);
|
||||
true
|
||||
}
|
||||
|
||||
/// Runs `op` on the guest's open station (window `window`, any when
|
||||
/// None) with their inventory in it, then sends what changed. False when
|
||||
/// no such window is open.
|
||||
fn lan_station_op(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
op: impl FnOnce(&mut Menu, &horizon_client::menu::Env),
|
||||
) -> bool {
|
||||
let Some(mut gs) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.station.take_if(|s| window.is_none_or(|w| s.window == w)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
{
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, op);
|
||||
self.lan_station_sounds(&mut gs);
|
||||
if let Some(amount) = gs.menu.ground.take() {
|
||||
self.grindstone_taken(gs.pos, amount);
|
||||
}
|
||||
self.lan_station_combined(host, conn, &mut gs);
|
||||
if let Some(button) = gs.menu.enchant_button.take() {
|
||||
self.lan_enchant(host, gi, &mut gs, button);
|
||||
}
|
||||
if std::mem::take(&mut gs.menu.enchant_changed) {
|
||||
self.lan_enchant_offers(host, gi, &mut gs);
|
||||
}
|
||||
}
|
||||
host.play.entry(conn).or_default().station = Some(gs);
|
||||
self.lan_send_station(host, conn);
|
||||
true
|
||||
}
|
||||
|
||||
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
|
||||
/// block entity, with its book and page.
|
||||
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
|
||||
self.lan_station_closed(host, conn, None);
|
||||
self.lan_crafting_closed(host, conn, None);
|
||||
self.guest_container_closed(host, conn);
|
||||
let title = horizon_protocol::text::translatable("container.lectern", &[]);
|
||||
let Some(window) = host.hub.open_window(conn, "minecraft:lectern", title) else {
|
||||
return;
|
||||
};
|
||||
host.play.entry(conn).or_default().lectern = Some(GuestLectern {
|
||||
window,
|
||||
state: 0,
|
||||
key,
|
||||
sent: None,
|
||||
});
|
||||
self.lan_send_lectern(host, conn, true);
|
||||
}
|
||||
|
||||
/// The lectern's book and page for the client: everything (`all`), or
|
||||
/// what changed since it was last sent.
|
||||
fn lan_send_lectern(&mut self, host: &mut LanHost, conn: u32, all: bool) {
|
||||
let Some(gl) = host.play.get_mut(&conn).and_then(|p| p.lectern.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let (book, page) = match self.world.block_entities.get(&gl.key) {
|
||||
Some(BlockEntity::Lectern(l)) => (l.book.clone(), l.page),
|
||||
_ => (None, 0),
|
||||
};
|
||||
let now = Some((book.clone(), page));
|
||||
if !all && gl.sent == now {
|
||||
return;
|
||||
}
|
||||
let page_changed = gl.sent.as_ref().is_none_or(|(_, p)| *p != page);
|
||||
gl.sent = now;
|
||||
gl.state = (gl.state + 1) & 0x7FFF;
|
||||
let (window, state) = (gl.window, gl.state);
|
||||
let carried = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.and_then(|g| g.carried.clone());
|
||||
let content = C::ContainerSetContent(play::ContainerSetContent {
|
||||
container: window,
|
||||
state,
|
||||
items: vec![host.hub.stack(&book)],
|
||||
carried: host.hub.stack(&carried),
|
||||
});
|
||||
host.hub.send_to(conn, &content);
|
||||
if all || page_changed {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerSetData(play::ContainerSetData {
|
||||
container: window,
|
||||
id: 0,
|
||||
value: page as i16,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `op` on the guest's lectern menu (window `window`) with their
|
||||
/// inventory, the block entity takes the book and page back, and the
|
||||
/// client gets what changed. False when no such window is open.
|
||||
fn lan_lectern_op(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: i32,
|
||||
op: impl FnOnce(&mut Menu, &horizon_client::menu::Env),
|
||||
) -> bool {
|
||||
let Some(key) = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|p| p.lectern.as_ref())
|
||||
.filter(|l| l.window == window)
|
||||
.map(|l| l.key)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& !host.hub.guests[gi].spectator()
|
||||
&& let Some(mut menu) = self.lectern_menu(key)
|
||||
{
|
||||
// Player.mayBuild: survival and creative.
|
||||
let may_build = matches!(host.hub.guests[gi].game_mode, 0 | 1);
|
||||
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
menu.may_build = may_build;
|
||||
op(menu, env);
|
||||
});
|
||||
self.lectern_menu_done(key, &menu);
|
||||
}
|
||||
self.lan_send_lectern(host, conn, true);
|
||||
true
|
||||
}
|
||||
|
||||
/// A click on the guest's lectern window; false for other windows.
|
||||
pub(super) fn lan_lectern_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &play::ContainerClick,
|
||||
) -> bool {
|
||||
self.lan_lectern_op(host, conn, p.container, |menu, env| {
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
})
|
||||
}
|
||||
|
||||
/// The rest of a taken anvil or smithing result's `onTake`: the guest
|
||||
/// pays the levels, the anvil wears (level event 1030 or 1029), the
|
||||
/// smithing table sounds (1044).
|
||||
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
|
||||
let change = std::mem::take(&mut gs.menu.level_change);
|
||||
if change != 0 {
|
||||
self.as_guest(host, conn, |g| g.give_levels(change));
|
||||
}
|
||||
if !std::mem::take(&mut gs.menu.combined) {
|
||||
return;
|
||||
}
|
||||
if gs.kind == StationKind::Smithing {
|
||||
self.level_event_at(1044, gs.pos, 0);
|
||||
} else {
|
||||
let creative = gs.menu.creative;
|
||||
// A broken anvil closes the menu on the next stillValid check.
|
||||
self.anvil_used(gs.pos, creative);
|
||||
}
|
||||
}
|
||||
|
||||
/// `EnchantmentMenu.slotsChanged` with a level: the costs and clues for
|
||||
/// the guest's seed and the bookshelves around the table.
|
||||
fn lan_enchant_offers(&mut self, host: &LanHost, gi: usize, gs: &mut GuestStation) {
|
||||
let seed = host.hub.guests[gi].xp_seed;
|
||||
let bookcases = self.count_bookshelves(gs.pos);
|
||||
let offers = horizon_client::stations::enchant_offers(
|
||||
&self.res.stations().enchantments,
|
||||
self.res.items(),
|
||||
seed,
|
||||
bookcases,
|
||||
gs.menu.slot(0),
|
||||
);
|
||||
for i in 0..3 {
|
||||
gs.menu.data[i] = offers.costs[i];
|
||||
let clue = offers.clues[i].as_ref();
|
||||
gs.menu.data[4 + i] = clue
|
||||
.and_then(|(e, _)| host.hub.registry_id("minecraft:enchantment", e))
|
||||
.unwrap_or(-1);
|
||||
gs.menu.data[7 + i] = clue.map_or(-1, |(_, l)| *l as i32);
|
||||
}
|
||||
gs.menu.data[3] = seed;
|
||||
}
|
||||
|
||||
/// `EnchantmentMenu.clickMenuButton`'s `access.execute`: the item gets
|
||||
/// the offer's enchantments, the guest pays its levels and lapis and
|
||||
/// gets a new seed, the table sounds, and the offers are made again.
|
||||
fn lan_enchant(&mut self, host: &mut LanHost, gi: usize, gs: &mut GuestStation, button: usize) {
|
||||
let Some(item) = gs.menu.slot(0).cloned() else {
|
||||
return;
|
||||
};
|
||||
let g = &host.hub.guests[gi];
|
||||
let (seed, creative, conn) = (g.xp_seed, g.creative(), g.conn);
|
||||
let Some(enchanted) = horizon_client::stations::apply_enchantment(
|
||||
&self.res.stations().enchantments,
|
||||
self.res.items(),
|
||||
seed,
|
||||
button,
|
||||
gs.menu.data[button],
|
||||
&item,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let need = button as i32 + 1;
|
||||
// Player.onEnchantmentPerformed, and EnchantmentMenu's awardStat(ENCHANT_ITEM).
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.give_levels(-need);
|
||||
game.stats.custom("enchant_item", 1);
|
||||
});
|
||||
host.hub.guests[gi].xp_seed = self.world.random.next_int();
|
||||
let env = self.menu_env();
|
||||
let lapis = gs.menu.slot(1).cloned().filter(|_| !creative).map(|mut l| {
|
||||
l.count = l.count.saturating_sub(need as u32);
|
||||
l
|
||||
});
|
||||
gs.menu.set_slot(&env, 0, Some(enchanted));
|
||||
if !creative {
|
||||
gs.menu.set_slot(&env, 1, lapis.filter(|l| l.count > 0));
|
||||
}
|
||||
gs.menu.enchant_changed = false;
|
||||
self.lan_enchant_offers(host, gi, gs);
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
let at = gs.pos.map(|v| f64::from(v) + 0.5);
|
||||
self.audio.play(
|
||||
"minecraft:block.enchantment_table.use",
|
||||
Some(at),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleRenameItem`: the guest's anvil
|
||||
/// takes the name (`AnvilMenu.setItemName`).
|
||||
pub(super) fn lan_station_rename(&mut self, host: &mut LanHost, conn: u32, name: &str) {
|
||||
let anvil = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|p| p.station.as_ref())
|
||||
.is_some_and(|s| s.kind == StationKind::Anvil);
|
||||
if anvil {
|
||||
self.lan_station_op(host, conn, None, |menu, env| {
|
||||
menu.rename(env, name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The sounds the menu played, at the station (heard by everyone around,
|
||||
/// as `level.playSound(null, pos, ...)`), once per tick.
|
||||
fn lan_station_sounds(&mut self, gs: &mut GuestStation) {
|
||||
let sounds = std::mem::take(&mut gs.menu.sounds);
|
||||
if sounds.is_empty() || gs.last_sound == self.ticks {
|
||||
return;
|
||||
}
|
||||
gs.last_sound = self.ticks;
|
||||
let at = gs.pos.map(|v| f64::from(v) + 0.5);
|
||||
for sound in sounds {
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on the guest's station window; false for other windows.
|
||||
pub(super) fn lan_station_click(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &play::ContainerClick,
|
||||
) -> bool {
|
||||
self.lan_station_op(host, conn, Some(p.container), |menu, env| {
|
||||
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
|
||||
})
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleContainerButtonClick`:
|
||||
/// `clickMenuButton`, then `broadcastChanges` when the menu took it.
|
||||
pub(super) fn lan_station_button(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: i32,
|
||||
id: i32,
|
||||
) {
|
||||
// LecternMenu's page buttons and Take Book.
|
||||
if self.lan_lectern_op(host, conn, window, |menu, env| {
|
||||
menu.button(env, id);
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
self.lan_station_op(host, conn, Some(window), |menu, env| {
|
||||
menu.button(env, id);
|
||||
});
|
||||
}
|
||||
|
||||
/// The whole station menu and its changed data slots for the client
|
||||
/// (`sendAllDataToRemote` / `broadcastChanges`).
|
||||
fn lan_send_station(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let Some(gs) = host.play.get_mut(&conn).and_then(|p| p.station.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
gs.state = (gs.state + 1) & 0x7FFF;
|
||||
let own = gs.menu.slot_count() - 36;
|
||||
let mut items: Vec<Option<ItemStack>> =
|
||||
(0..own).map(|s| gs.menu.slot(s).cloned()).collect();
|
||||
let g = &host.hub.guests[gi];
|
||||
items.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone()));
|
||||
let carried = g.carried.clone();
|
||||
let packet = C::ContainerSetContent(play::ContainerSetContent {
|
||||
container: gs.window,
|
||||
state: gs.state,
|
||||
items: items.iter().map(|s| host.hub.stack(s)).collect(),
|
||||
carried: host.hub.stack(&carried),
|
||||
});
|
||||
gs.sent_data.resize(gs.menu.data.len(), i32::MIN);
|
||||
let mut data = Vec::new();
|
||||
for (k, &v) in gs.menu.data.iter().enumerate() {
|
||||
if gs.sent_data[k] != v {
|
||||
gs.sent_data[k] = v;
|
||||
data.push(C::ContainerSetData(play::ContainerSetData {
|
||||
container: gs.window,
|
||||
id: k as i16,
|
||||
value: v as i16,
|
||||
}));
|
||||
}
|
||||
}
|
||||
host.hub.send_to(conn, &packet);
|
||||
for packet in data {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.removed` of the guest's station (`window`;
|
||||
/// None closes it whichever it is): the carried stack and the inputs go
|
||||
/// back to the inventory, what doesn't fit is dropped. False when no such
|
||||
/// window is open.
|
||||
pub(super) fn lan_station_closed(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
window: Option<i32>,
|
||||
) -> bool {
|
||||
// A lectern's menu keeps nothing of its own (the book is the block entity's).
|
||||
let lectern = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.lectern.take_if(|l| window.is_none_or(|w| l.window == w)));
|
||||
if lectern.is_some() {
|
||||
// AbstractContainerMenu.removed: the carried stack goes back.
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& let Some(carried) = host.hub.guests[gi].carried.take()
|
||||
{
|
||||
self.lan_place_back(host, gi, vec![carried]);
|
||||
}
|
||||
if window.is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(gs) = host
|
||||
.play
|
||||
.get_mut(&conn)
|
||||
.and_then(|p| p.station.take_if(|s| window.is_none_or(|w| s.window == w)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return true;
|
||||
};
|
||||
// AbstractContainerMenu.removed: the carried stack first, then the inputs.
|
||||
let mut back: Vec<ItemStack> = host.hub.guests[gi].carried.take().into_iter().collect();
|
||||
let inputs = station_inputs(&gs);
|
||||
if gs.kind == StationKind::Beacon {
|
||||
// BeaconMenu.removed drops the payment (player.drop).
|
||||
if !back.is_empty() {
|
||||
self.lan_place_back(host, gi, std::mem::take(&mut back));
|
||||
}
|
||||
self.drop_guest_items(host, gi, inputs);
|
||||
} else {
|
||||
back.extend(inputs);
|
||||
}
|
||||
if !back.is_empty() {
|
||||
self.lan_place_back(host, gi, back);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `BeaconBlockEntity.dataAccess`: the pyramid's levels and the powers
|
||||
/// (`BeaconMenu.encodeEffect`: the `mob_effect` id + 1, 0 for none).
|
||||
fn beacon_data(&self, host: &LanHost, pos: [i32; 3]) -> [i32; 3] {
|
||||
let Some(BlockEntity::Beacon(b)) = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
return [0; 3];
|
||||
};
|
||||
let encode = |e: &Option<String>| {
|
||||
e.as_deref()
|
||||
.and_then(|n| host.hub.registry_id("minecraft:mob_effect", n))
|
||||
.map_or(0, |id| id + 1)
|
||||
};
|
||||
[b.levels, encode(&b.primary), encode(&b.secondary)]
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handleSetBeaconPacket`: the guest's
|
||||
/// beacon menu takes the powers (`BeaconMenu.updateEffects`), and they
|
||||
/// become the block entity's (`dataAccess.set`: the power_select sound
|
||||
/// while the beam is up).
|
||||
pub(super) fn lan_station_beacon(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
primary: Option<i32>,
|
||||
secondary: Option<i32>,
|
||||
) {
|
||||
let Some(pos) = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.and_then(|p| p.station.as_ref())
|
||||
.filter(|s| s.kind == StationKind::Beacon)
|
||||
.map(|s| s.pos)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// An id the registry doesn't have fails the packet's codec.
|
||||
let mut powers: [Option<(i32, String)>; 2] = [None, None];
|
||||
for (k, id) in [primary, secondary].into_iter().enumerate() {
|
||||
if let Some(id) = id {
|
||||
let Some(name) = host.hub.registry_name("minecraft:mob_effect", id) else {
|
||||
return;
|
||||
};
|
||||
powers[k] = Some((id, name.to_owned()));
|
||||
}
|
||||
}
|
||||
let data = self.beacon_data(host, pos);
|
||||
let mut updated = false;
|
||||
self.lan_station_op(host, conn, None, |menu, env| {
|
||||
menu.data.copy_from_slice(&data);
|
||||
let power = |k: usize| powers[k].as_ref().map(|(id, n)| (*id, n.as_str()));
|
||||
updated = menu.beacon_update(env, power(0), power(1));
|
||||
});
|
||||
if !updated {
|
||||
return;
|
||||
}
|
||||
let [primary, secondary] = powers.map(|p| p.map(|(_, n)| n));
|
||||
let mut select_sound = false;
|
||||
if let Some(BlockEntity::Beacon(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.primary = primary;
|
||||
b.secondary = secondary;
|
||||
select_sound = !b.sections.is_empty();
|
||||
}
|
||||
if select_sound {
|
||||
self.beacon_sound(pos, "minecraft:block.beacon.power_select");
|
||||
}
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
|
||||
/// `ServerPlayer.doTick`'s `stillValid`: a guest's station closes once its
|
||||
/// block is gone or the guest is out of reach (`isWithinBlockInteractionRange(pos, 4)`).
|
||||
pub(super) fn lan_stations_tick(&mut self, host: &mut LanHost) {
|
||||
self.lan_lecterns_tick(host);
|
||||
let conns: Vec<u32> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter(|(_, p)| p.station.is_some())
|
||||
.map(|(c, _)| *c)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
let Some(gs) = host.play.get(&conn).and_then(|p| p.station.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let [x, y, z] = gs.pos;
|
||||
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
|
||||
let block = StationKind::from_block(name) == Some(gs.kind);
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
// The block interaction range (4.5, creative 5) and the buffer.
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(gs.pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
if !(block && near) {
|
||||
let window = gs.window;
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerClose(play::ContainerClose { container: window }),
|
||||
);
|
||||
} else if gs.kind == StationKind::Beacon {
|
||||
// The beacon's data slots follow its block entity.
|
||||
let data = self.beacon_data(host, gs.pos);
|
||||
if let Some(gs) = host.play.get_mut(&conn).and_then(|p| p.station.as_mut())
|
||||
&& gs.menu.data[..] != data[..]
|
||||
{
|
||||
gs.menu.data.copy_from_slice(&data);
|
||||
self.lan_send_station(host, conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The guests' lecterns each tick: `stillValid` (the block entity, its
|
||||
/// book, the reach) closes them, and `broadcastChanges` sends a book or
|
||||
/// page another player changed.
|
||||
fn lan_lecterns_tick(&mut self, host: &mut LanHost) {
|
||||
let open: Vec<(u32, i32, (i32, i32, i32))> = host
|
||||
.play
|
||||
.iter()
|
||||
.filter_map(|(c, p)| p.lectern.as_ref().map(|l| (*c, l.window, l.key)))
|
||||
.collect();
|
||||
for (conn, window, key) in open {
|
||||
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
|
||||
continue;
|
||||
};
|
||||
if !host.hub.here(g) {
|
||||
continue;
|
||||
}
|
||||
let has_book = matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
Some(BlockEntity::Lectern(l)) if l.book.is_some()
|
||||
);
|
||||
// Container.stillValidBlockEntity: the interaction range and its buffer.
|
||||
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
|
||||
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let near = (0..3)
|
||||
.map(|k| {
|
||||
let lo = f64::from(pos[k]);
|
||||
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
|
||||
})
|
||||
.sum::<f64>()
|
||||
< reach * reach;
|
||||
if has_book && near {
|
||||
self.lan_send_lectern(host, conn, false);
|
||||
} else {
|
||||
self.lan_station_closed(host, conn, Some(window));
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::ContainerClose(play::ContainerClose { container: window }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `clearContainer` gives back from a station's menu.
|
||||
pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
|
||||
menu_of(gs.kind)
|
||||
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -55,8 +55,9 @@ impl Game<'_> {
|
||||
state.session.update_special_prices();
|
||||
}
|
||||
let title = self.merchant_display_name(&state);
|
||||
// The container the trading screen replaces loses its opener.
|
||||
// The container or workstation the trading screen replaces is closed.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
let Some(window) = host.hub.open_merchant_window(conn, title) else {
|
||||
self.merchant_closed(&mut state);
|
||||
return;
|
||||
@@ -254,7 +255,11 @@ impl Game<'_> {
|
||||
recipes: self.res.recipes(),
|
||||
brewing: &self.res.stations().brewing,
|
||||
banners: &self.res.stations().banners,
|
||||
station_recipes: &self.res.stations().recipes,
|
||||
enchantments: &self.res.stations().enchantments,
|
||||
names: &self.pack.names,
|
||||
trade: Some(&trade_env),
|
||||
maps: None,
|
||||
};
|
||||
let mut menu = Menu::new(Kind::Merchant);
|
||||
{
|
||||
@@ -393,8 +398,8 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Takes a station a guest's action opened: a trading screen goes to the
|
||||
/// guest; other stations are not shown to guests yet.
|
||||
/// Takes a station a guest's action opened: a trading screen or a
|
||||
/// workstation menu goes to the guest (game/lan_stations.rs).
|
||||
pub(super) fn lan_station_opened(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
@@ -406,6 +411,8 @@ impl Game<'_> {
|
||||
&& let Some(m) = station.merchant.take()
|
||||
{
|
||||
self.lan_open_merchant(host, conn, *m);
|
||||
return;
|
||||
}
|
||||
self.lan_open_station(host, conn, &station);
|
||||
}
|
||||
}
|
||||
@@ -312,15 +312,10 @@ impl Game<'_> {
|
||||
.map_or_else(String::new, |(end, start)| {
|
||||
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
|
||||
});
|
||||
let list: Vec<String> = spans
|
||||
.iter()
|
||||
.filter(|(_, ms)| *ms >= 0.5)
|
||||
.map(|(n, ms)| format!("{n} {ms:.1}"))
|
||||
.collect();
|
||||
log::info!(
|
||||
"Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}",
|
||||
self.mobs.len(),
|
||||
list.join(", ")
|
||||
horizon_client::spans::describe(&spans)
|
||||
);
|
||||
}
|
||||
result
|
||||
|
||||
@@ -68,8 +68,9 @@ impl Game<'_> {
|
||||
}
|
||||
l.last_inputs = inputs;
|
||||
}
|
||||
/// A sound the loom menu played: the result's once per tick.
|
||||
pub(super) fn loom_sound(&mut self, sound: &str) {
|
||||
/// A sound the loom or stonecutter menu played: the result's once per
|
||||
/// tick (`lastSoundTime`).
|
||||
pub(super) fn station_sound(&mut self, sound: &str) {
|
||||
let ticks = self.ticks;
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
@@ -98,6 +99,7 @@ impl Game<'_> {
|
||||
if taken {
|
||||
self.audio
|
||||
.play("minecraft:ui.loom.select_pattern", None, 1.0, 1.0);
|
||||
self.remote_station_button(index as i32);
|
||||
}
|
||||
taken
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use horizon_client::{
|
||||
containers::Crafter,
|
||||
hud::{HudBatch, rgba},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
use serde_json::Value;
|
||||
|
||||
/// `CrafterBlock`'s crafting animation length.
|
||||
@@ -323,6 +324,15 @@ impl Game<'_> {
|
||||
menu.set_slot_state(i, enabled);
|
||||
Some(true)
|
||||
});
|
||||
if let Some((container, _)) = self.remote.as_ref().and_then(|r| r.menu.clone()) {
|
||||
self.send(S::ContainerSlotStateChanged(
|
||||
play::ContainerSlotStateChanged {
|
||||
slot: i as i32,
|
||||
container,
|
||||
enabled,
|
||||
},
|
||||
));
|
||||
}
|
||||
let pitch = if enabled { 1.0 } else { 0.75 };
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.4, pitch);
|
||||
|
||||
@@ -191,7 +191,13 @@ impl NaturalLevel for GameLevel<'_, '_> {
|
||||
StructureSpawns::shared(&self.game.options.resources)
|
||||
}
|
||||
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
|
||||
nearest_player(&self.players, x, y, z).map(|p| p.distance_sqr(x, y, z))
|
||||
// The nearest player's distance (`nearest_player`), without finding
|
||||
// which player it is: every spawn attempt asks.
|
||||
self.players
|
||||
.iter()
|
||||
.filter(|p| !p.spectator)
|
||||
.map(|p| p.distance_sqr(x, y, z))
|
||||
.reduce(f64::min)
|
||||
}
|
||||
fn respawn(&self) -> Option<[i32; 3]> {
|
||||
// The world spawn point lives in the overworld.
|
||||
|
||||
@@ -312,6 +312,7 @@ impl Game<'_> {
|
||||
let crouching = p.pose == Pose::Crouching;
|
||||
let (position, flying) = (p.position, p.flying || p.fall_flying);
|
||||
let (fall_flying, fly_ticks, velocity) = (p.fall_flying, p.fall_fly_ticks, p.velocity);
|
||||
let spinning = p.auto_spin_attack_ticks > 0;
|
||||
let using = self.using.map(|(_, ticks)| ticks as i32 + 1).unwrap_or(0);
|
||||
let using_off_hand = self
|
||||
.using
|
||||
@@ -397,6 +398,7 @@ impl Game<'_> {
|
||||
m.velocity = velocity;
|
||||
m.extra.fall_flying = fall_flying;
|
||||
m.extra.fall_fly_ticks = fly_ticks;
|
||||
m.extra.auto_spin_attack = spinning;
|
||||
m.extra.main_hand_left = self.settings.main_hand_left;
|
||||
m.extra.using_item = using;
|
||||
m.extra.using_off_hand = using_off_hand;
|
||||
@@ -451,6 +453,7 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
}
|
||||
/// `extractEntityInInventoryFollowsMouse`: the player in the box
|
||||
@@ -592,6 +595,47 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning
|
||||
/// player (`SpinAttackEffectModel.setupAnim`).
|
||||
fn spin_attack_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
camera: glam::DVec3,
|
||||
partial: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
if !m.extra.auto_spin_attack {
|
||||
return;
|
||||
}
|
||||
let (Some(model), Some(rect)) = (
|
||||
self.res.spin_attack().as_ref(),
|
||||
self.gui.atlas.sprites.get("entity/trident/trident_riptide"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let age = m.age as f32 + partial;
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
horizon_client::mobs::render::base_transform(m, partial, camera),
|
||||
&|part| {
|
||||
let mut p = part.pose;
|
||||
if let Some(i) = part
|
||||
.name
|
||||
.strip_prefix("box")
|
||||
.and_then(|i| i.parse::<u8>().ok())
|
||||
{
|
||||
let angle = age * -(45.0 + f32::from(i + 1) * 5.0);
|
||||
p[4] = wrap(angle).to_radians();
|
||||
}
|
||||
p
|
||||
},
|
||||
[rect.u0, rect.v0, rect.u1, rect.v1],
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn player_preview_quads(
|
||||
|
||||
@@ -279,7 +279,11 @@ impl Game<'_> {
|
||||
recipes: self.res.recipes(),
|
||||
brewing: &self.res.stations().brewing,
|
||||
banners: &self.res.stations().banners,
|
||||
station_recipes: &self.res.stations().recipes,
|
||||
enchantments: &self.res.stations().enchantments,
|
||||
names: &self.pack.names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
};
|
||||
for i in 0..36 {
|
||||
menu.set_player_item(i, self.inventory[i].clone());
|
||||
|
||||
@@ -41,7 +41,11 @@ use horizon_protocol::{
|
||||
text::{Language, format_java, plain},
|
||||
};
|
||||
use horizon_world::{nbt::Tag, registry::BlockStateId};
|
||||
use std::{collections::HashMap, sync::Arc, time::Instant};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// A text component the server sent, for the HUD.
|
||||
@@ -112,9 +116,9 @@ pub(super) struct Remote {
|
||||
/// only replace the remembered state; then the block takes it.
|
||||
predictions: HashMap<[i32; 3], (i32, i32)>,
|
||||
/// Open container: (window id, menu name).
|
||||
menu: Option<(i32, String)>,
|
||||
pub(super) menu: Option<(i32, String)>,
|
||||
/// The open menu's title as the server named it.
|
||||
menu_title: Option<String>,
|
||||
pub(super) menu_title: Option<String>,
|
||||
state_id: i32,
|
||||
use_target: Option<[i32; 3]>,
|
||||
entities: HashMap<i32, Tracked>,
|
||||
@@ -162,8 +166,22 @@ pub(super) struct Remote {
|
||||
clock_game_time: i64,
|
||||
/// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket).
|
||||
chunk_center: (i32, i32),
|
||||
/// Received events a frame left for the next one, in order, and when
|
||||
/// the oldest of them first waited (`poll_remote`).
|
||||
backlog: VecDeque<Event>,
|
||||
backlog_since: Option<Instant>,
|
||||
}
|
||||
|
||||
/// The time a frame spends on chunk packets before it leaves the rest of
|
||||
/// the received events for the next frame. A batch of 25 to 60 columns
|
||||
/// (0.1 ms each on an M4 Pro) all in one frame was a 3 to 8 ms frame every
|
||||
/// time the player crossed a chunk border at 32 chunks.
|
||||
const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1);
|
||||
/// Events that have waited this long are all handled in the next frame,
|
||||
/// whatever their cost: the packets after a slow machine's chunks never
|
||||
/// wait more than a tick.
|
||||
const BACKLOG_LIMIT: Duration = Duration::from_millis(50);
|
||||
|
||||
fn face_direction(face: [i32; 3]) -> u8 {
|
||||
match face {
|
||||
[0, -1, 0] => 0,
|
||||
@@ -355,6 +373,8 @@ impl Game<'_> {
|
||||
clock_partial: 0.0,
|
||||
clock_game_time: 0,
|
||||
chunk_center: (0, 0),
|
||||
backlog: VecDeque::new(),
|
||||
backlog_since: None,
|
||||
});
|
||||
// The level's TickRateManager follows the server's ticking packets.
|
||||
self.clock = horizon_client::clock::FixedStep::client();
|
||||
@@ -451,8 +471,15 @@ impl Game<'_> {
|
||||
.unwrap_or("minecraft:air"),
|
||||
)
|
||||
}
|
||||
/// The server's network id of a mob effect (`brewing::EFFECTS` id).
|
||||
pub(super) fn remote_effect_network_id(&self, effect: &str) -> Option<i32> {
|
||||
let r = self.remote.as_ref()?;
|
||||
r.builtin
|
||||
.get("minecraft:mob_effect")?
|
||||
.id(&format!("minecraft:{effect}"))
|
||||
}
|
||||
/// A mob effect's network id as the effect (`brewing::EFFECTS` id).
|
||||
fn remote_effect_id(&self, effect: i32) -> Option<&'static str> {
|
||||
pub(super) fn remote_effect_id(&self, effect: i32) -> Option<&'static str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let name = r.builtin.get("minecraft:mob_effect")?.name(effect)?;
|
||||
horizon_client::brewing::effect(name).map(|d| d.id)
|
||||
@@ -498,6 +525,54 @@ impl Game<'_> {
|
||||
if let Some(Component::Int(id)) = s.component(component::MAP_ID) {
|
||||
out.data_mut().map_id = Some(*id);
|
||||
}
|
||||
if let Some(Component::Int(cost)) = s.component(component::REPAIR_COST) {
|
||||
out.data_mut().repair_cost = (*cost).max(0) as u32;
|
||||
}
|
||||
if let Some(Component::Int(mode)) = s.component(component::MAP_POST_PROCESSING) {
|
||||
out.data_mut().map_post_processing = Some((*mode).clamp(0, 1) as u8);
|
||||
}
|
||||
if let Some(Component::WritableBook(pages)) = s.component(component::WRITABLE_BOOK_CONTENT)
|
||||
{
|
||||
out.data_mut().writable_pages = pages.clone();
|
||||
}
|
||||
if let Some(Component::WrittenBook(b)) = s.component(component::WRITTEN_BOOK_CONTENT) {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
out.data_mut().written = Some(horizon_client::item_data::WrittenBook {
|
||||
title: b.title.clone(),
|
||||
author: b.author.clone(),
|
||||
generation: b.generation.clamp(0, 3) as u8,
|
||||
pages: b.pages.iter().map(|p| plain(p, &lang)).collect(),
|
||||
});
|
||||
}
|
||||
if let Some(Component::Text(name)) = s.component(component::CUSTOM_NAME) {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
out.data_mut().custom_name = Some(plain(name, &lang));
|
||||
}
|
||||
// The enchantments by the server's registry ids.
|
||||
let levels = |list: &[(i32, i32)]| -> Vec<(Arc<str>, u32)> {
|
||||
let names = self.remote.as_ref().map(|r| &r.registries);
|
||||
list.iter()
|
||||
.filter_map(|(id, level)| {
|
||||
let name = names?.name("minecraft:enchantment", *id)?;
|
||||
Some((Arc::from(name), (*level).max(0) as u32))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if let Some(Component::Enchantments(list)) = s.component(component::ENCHANTMENTS) {
|
||||
out.data_mut().enchantments = levels(list);
|
||||
}
|
||||
if let Some(Component::Enchantments(list)) = s.component(component::STORED_ENCHANTMENTS) {
|
||||
out.data_mut().stored_enchantments = levels(list);
|
||||
}
|
||||
out.tidy();
|
||||
Some(out)
|
||||
}
|
||||
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
|
||||
@@ -571,12 +646,34 @@ impl Game<'_> {
|
||||
Some(r.chat.iter().rev().take(n).map(|l| l.0.clone()).collect())
|
||||
}
|
||||
|
||||
/// Process everything the network thread received (once per frame, like
|
||||
/// the client's packet processor).
|
||||
/// Received events left for the next frame (`poll_remote`): until they
|
||||
/// are handled, the terrain the server sent is not all there.
|
||||
pub(super) fn remote_backlog(&self) -> usize {
|
||||
self.remote.as_ref().map_or(0, |r| r.backlog.len())
|
||||
}
|
||||
/// Process what the network thread received (once per frame, like the
|
||||
/// client's packet processor), in order. Chunk packets get
|
||||
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
|
||||
/// the next chunk packet on wait for the next frame, unless they have
|
||||
/// already waited `BACKLOG_LIMIT`.
|
||||
pub(super) fn poll_remote(&mut self) -> Result<()> {
|
||||
let Some(r) = &self.remote else { return Ok(()) };
|
||||
let events = r.session.poll();
|
||||
for event in events {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut events = std::mem::take(&mut r.backlog);
|
||||
events.extend(r.session.poll());
|
||||
let overdue = r
|
||||
.backlog_since
|
||||
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
|
||||
let mut chunks = Duration::ZERO;
|
||||
while let Some(event) = events.pop_front() {
|
||||
let chunk =
|
||||
matches!(&event, Event::Packet(p) if matches!(**p, C::LevelChunkWithLight(_)));
|
||||
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
|
||||
events.push_front(event);
|
||||
break;
|
||||
}
|
||||
let started = chunk.then(Instant::now);
|
||||
match event {
|
||||
Event::Status(s) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -622,10 +719,19 @@ impl Game<'_> {
|
||||
}
|
||||
Event::Packet(packet) => self.handle_packet(*packet)?,
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
if self.remote.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.backlog_since = if events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(r.backlog_since.unwrap_or_else(Instant::now))
|
||||
};
|
||||
r.backlog = events;
|
||||
}
|
||||
self.send_suggestion_request();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1243,9 +1349,39 @@ impl Game<'_> {
|
||||
"minecraft:hopper" => slots(5),
|
||||
"minecraft:generic_3x3" => slots(9),
|
||||
"minecraft:brewing_stand" => slots(5),
|
||||
_ => None,
|
||||
// CrafterMenu: the grid, the inventory, then the result (the
|
||||
// client's own).
|
||||
"minecraft:crafter_3x3" => slots(9),
|
||||
// A workstation: its inputs, its result, then the inventory.
|
||||
m => {
|
||||
let kind = Self::remote_station_kind(m)?;
|
||||
let inputs = kind.inputs();
|
||||
let own = inputs + usize::from(kind.has_result());
|
||||
match s {
|
||||
_ if s < inputs => Some(super::stations::INPUT + s),
|
||||
_ if s < own => Some(super::stations::RESULT),
|
||||
_ if s < own + 27 => Some(9 + s - own),
|
||||
_ if s < own + 36 => Some(s - own - 27),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The workstation screen of a menu type a server opens.
|
||||
pub(super) fn remote_station_kind(menu: &str) -> Option<super::stations::Kind> {
|
||||
use super::stations::Kind;
|
||||
Some(match menu {
|
||||
"minecraft:loom" => Kind::Loom,
|
||||
"minecraft:stonecutter" => Kind::Stonecutter,
|
||||
"minecraft:grindstone" => Kind::Grindstone,
|
||||
"minecraft:anvil" => Kind::Anvil,
|
||||
"minecraft:smithing" => Kind::Smithing,
|
||||
"minecraft:enchantment" => Kind::Enchanting,
|
||||
"minecraft:beacon" => Kind::Beacon,
|
||||
"minecraft:cartography_table" => Kind::Cartography,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn open_menu_name(&self, container: i32) -> String {
|
||||
match self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
|
||||
Some((id, name)) if *id == container && container != 0 => name.clone(),
|
||||
@@ -1254,6 +1390,12 @@ impl Game<'_> {
|
||||
}
|
||||
fn set_menu_slot(&mut self, container: i32, slot: i32, item: Option<ItemStack>) {
|
||||
let menu = self.open_menu_name(container);
|
||||
if menu == "minecraft:lectern" {
|
||||
if slot == 0 {
|
||||
self.remote_lectern_book(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if container != 0 && menu == "inventory" {
|
||||
return;
|
||||
}
|
||||
@@ -1264,6 +1406,9 @@ impl Game<'_> {
|
||||
return; // the result slot is recomputed from the grid
|
||||
}
|
||||
*self.slot_mut(internal) = item;
|
||||
if internal >= super::stations::INPUT && self.station.is_some() {
|
||||
self.remote_station_slot_changed(internal);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_packet(&mut self, packet: C) -> Result<()> {
|
||||
@@ -1909,6 +2054,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.remote_synced();
|
||||
}
|
||||
C::ContainerSetData(p) if self.open_menu_name(p.container) == "minecraft:lectern" => {
|
||||
self.remote_lectern_page(i32::from(p.value));
|
||||
}
|
||||
C::ContainerSetData(p) if self.remote_station_open(p.container) => {
|
||||
self.remote_station_data(usize::try_from(p.id).unwrap_or(usize::MAX), p.value);
|
||||
}
|
||||
C::ContainerSetData(p) => {
|
||||
let v = i32::from(p.value);
|
||||
match self
|
||||
@@ -1922,6 +2073,12 @@ impl Game<'_> {
|
||||
3 => f.cooking_total = v,
|
||||
_ => {}
|
||||
},
|
||||
// CrafterBlockEntity.containerData.
|
||||
Some(BlockEntity::Crafter(c)) => match usize::try_from(p.id) {
|
||||
Ok(k @ 0..9) => c.disabled[k] = v == 1,
|
||||
Ok(9) => c.triggered = v == 1,
|
||||
_ => {}
|
||||
},
|
||||
// BrewingStandBlockEntity.dataAccess.
|
||||
Some(BlockEntity::Brewing(b)) => match p.id {
|
||||
0 => b.brew_time = v,
|
||||
@@ -1939,6 +2096,7 @@ impl Game<'_> {
|
||||
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
|
||||
C::PlaceGhostRecipe(p) => self.remote_ghost_recipe(p),
|
||||
C::MerchantOffers(p) => self.remote_merchant_offers(p),
|
||||
C::ContainerClose(_) if self.remote_lectern_closed() => {}
|
||||
C::ContainerClose(_) => {
|
||||
if self.screen == Screen::Inventory {
|
||||
self.remote_close_local();
|
||||
@@ -3785,6 +3943,10 @@ impl Game<'_> {
|
||||
/// engine doesn't model sends the clicks unpredicted. False when a click's
|
||||
/// slot isn't one of the menu's.
|
||||
pub(super) fn remote_menu_ops(&mut self, ops: &[(Option<usize>, i32, Input)]) -> bool {
|
||||
// CrafterScreen.slotClicked: a slot toggle goes out before the click.
|
||||
if let [(Some(slot), button, input)] = ops {
|
||||
self.crafter_slot_clicked(*slot, *button, *input);
|
||||
}
|
||||
let (container, menu) = self.remote_menu();
|
||||
let mut clicks = Vec::with_capacity(ops.len());
|
||||
for &(slot, button, input) in ops {
|
||||
@@ -3937,13 +4099,8 @@ impl Game<'_> {
|
||||
r.menu = None;
|
||||
r.menu_title = None;
|
||||
}
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == super::stations::Kind::Merchant)
|
||||
{
|
||||
self.station = None;
|
||||
}
|
||||
// The client's menu has no level access: what it holds is the server's.
|
||||
self.station = None;
|
||||
self.craft_size = 2;
|
||||
if let Some(key) = self.container.take() {
|
||||
// Server containers stay renderable but lose their copied slots.
|
||||
@@ -4017,6 +4174,27 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|r| r.use_target)
|
||||
.map(|t| (t[0], t[1], t[2]));
|
||||
if menu == "minecraft:lectern" {
|
||||
// LecternScreen: the book and page come in the menu's content and data.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = Some((p.container, menu));
|
||||
}
|
||||
self.remote_open_lectern(target.unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
if let Some(kind) = Self::remote_station_kind(&menu) {
|
||||
let title = plain(
|
||||
&p.title,
|
||||
&self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let pos = target.map_or([0, 0, 0], |t| [t.0, t.1, t.2]);
|
||||
self.remote_open_station(p.container, menu, kind, title, pos);
|
||||
return;
|
||||
}
|
||||
if menu == "minecraft:merchant" {
|
||||
// A trading window: its offers follow in a MerchantOffers packet.
|
||||
let title = plain(
|
||||
@@ -4056,6 +4234,8 @@ impl Game<'_> {
|
||||
horizon_client::brewing::BrewingStand::new(),
|
||||
))),
|
||||
"minecraft:generic_3x3" => Some(BlockEntity::Chest(vec![None; 9])),
|
||||
// CrafterMenu: the disabled slots and triggered come in ContainerSetData.
|
||||
"minecraft:crafter_3x3" => Some(BlockEntity::Crafter(Default::default())),
|
||||
m if m.starts_with("minecraft:generic_9x") || m == "minecraft:shulker_box" => {
|
||||
let rows = m
|
||||
.strip_prefix("minecraft:generic_9x")
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! A server's workstation menus on this client: the server opens the menu
|
||||
//! (`ClientboundOpenScreenPacket` with the station's `MenuType`), the
|
||||
//! station screen draws it, and its clicks and buttons are predicted on the
|
||||
//! menu engine (`Game::menu_session`) and sent, as
|
||||
//! `MultiPlayerGameMode.handleContainerInput` and `handleInventoryButtonClick`
|
||||
//! do. The menu's client-side `slotsChanged` follows the server's slots, and
|
||||
//! its data slots come in `ClientboundContainerSetDataPacket`.
|
||||
use super::{
|
||||
Game, Screen,
|
||||
stations::{Kind, Station},
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The server opened workstation menu `menu` in window `container`.
|
||||
pub(super) fn remote_open_station(
|
||||
&mut self,
|
||||
container: i32,
|
||||
menu: String,
|
||||
kind: Kind,
|
||||
title: String,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu = Some((container, menu));
|
||||
r.menu_title = Some(title);
|
||||
}
|
||||
self.craft_size = 2;
|
||||
self.container = None;
|
||||
self.station = Some(Station::new_kind(kind, pos));
|
||||
self.screen = Screen::Inventory;
|
||||
self.capture(false);
|
||||
}
|
||||
|
||||
/// Window `container` is the server's workstation menu, open here.
|
||||
pub(super) fn remote_station_open(&self, container: i32) -> bool {
|
||||
self.station.is_some()
|
||||
&& self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.menu.as_ref())
|
||||
.is_some_and(|(id, name)| {
|
||||
*id == container && Self::remote_station_kind(name).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// The server set station slot `internal`: the client menu's
|
||||
/// `Slot.set` runs its container's `setChanged`, so the loom lists the
|
||||
/// patterns its inputs allow and the stonecutter the recipes of its input.
|
||||
pub(super) fn remote_station_slot_changed(&mut self, internal: usize) {
|
||||
if !self.station.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.kind,
|
||||
Kind::Loom
|
||||
| Kind::Stonecutter
|
||||
| Kind::Grindstone
|
||||
| Kind::Anvil
|
||||
| Kind::Smithing
|
||||
| Kind::Cartography
|
||||
)
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
self.menu_session(|menu, env, to_menu| {
|
||||
let i = to_menu(internal)?;
|
||||
let stack = menu.slot(i).cloned();
|
||||
menu.set_slot(env, i, stack);
|
||||
Some(true)
|
||||
});
|
||||
// AnvilScreen.slotChanged: the box shows the new input's name, which
|
||||
// its responder sends.
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.setData` from the server.
|
||||
pub(super) fn remote_station_data(&mut self, id: usize, value: i16) {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match (s.kind, id) {
|
||||
// LoomMenu.selectedBannerPatternIndex.
|
||||
(Kind::Loom, 0) => s.loom.selected = usize::try_from(value).ok(),
|
||||
// StonecutterMenu.selectedRecipeIndex.
|
||||
(Kind::Stonecutter, 0) => s.selected = usize::try_from(value).ok(),
|
||||
// AnvilMenu.cost.
|
||||
(Kind::Anvil, 0) => s.anvil.cost = i32::from(value),
|
||||
// EnchantmentMenu: costs, the seed (the screen's glyph names),
|
||||
// the clues' enchantments and levels.
|
||||
(Kind::Enchanting, 0..3) => s.offers.costs[id] = i32::from(value),
|
||||
(Kind::Enchanting, 3) => self.level.xp_seed = i32::from(value),
|
||||
(Kind::Enchanting, 4..7) => {
|
||||
let name = self.remote.as_ref().and_then(|r| {
|
||||
r.registries
|
||||
.name("minecraft:enchantment", i32::from(value))
|
||||
.map(std::sync::Arc::<str>::from)
|
||||
});
|
||||
let level = s.offers.clues[id - 4].as_ref().map_or(1, |(_, l)| *l);
|
||||
s.offers.clues[id - 4] = name.map(|n| (n, level));
|
||||
}
|
||||
(Kind::Enchanting, 7..10) => {
|
||||
if let Some((_, level)) = s.offers.clues[id - 7].as_mut() {
|
||||
*level = value.max(0) as u32;
|
||||
}
|
||||
}
|
||||
// BeaconMenu: the levels, then the powers (BeaconScreen's
|
||||
// dataChanged shows the menu's).
|
||||
(Kind::Beacon, 0) => s.beacon_levels = Some(i32::from(value)),
|
||||
(Kind::Beacon, 1 | 2) => {
|
||||
let effect = self.remote_effect_id(i32::from(value) - 1);
|
||||
let index =
|
||||
effect.and_then(|e| super::beacon::EFFECTS.iter().position(|(n, _)| *n == e));
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
s.beacon[id - 1] = index;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `AnvilScreen.onNameChanged` on a server: the rename box's text (blank
|
||||
/// when it is the input's own name) goes to the server when the menu's
|
||||
/// `setItemName` takes it.
|
||||
pub(super) fn remote_anvil_rename(&mut self) {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(s) = self
|
||||
.station
|
||||
.as_ref()
|
||||
.filter(|s| s.kind == Kind::Anvil && s.inputs[0].is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let name = self.anvil_item_name(s);
|
||||
if s.anvil_sent.as_deref() == Some(&name) {
|
||||
return;
|
||||
}
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
s.anvil_sent = Some(name.clone());
|
||||
}
|
||||
self.send(S::RenameItem(play::RenameItem { name }));
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.handleInventoryButtonClick`: the screen already
|
||||
/// ran `clickMenuButton` on its own menu.
|
||||
pub(super) fn remote_station_button(&self, button: i32) {
|
||||
if let Some((container, _)) = self.remote.as_ref().and_then(|r| r.menu.as_ref())
|
||||
&& self.remote_station_open(*container)
|
||||
{
|
||||
self.send(S::ContainerButtonClick(play::ContainerButtonClick {
|
||||
container: *container,
|
||||
button,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub(super) const HELP: &[&str] = &[
|
||||
"use X Y Z open the block's menu as if right-clicked",
|
||||
"click-slot ID [right] [shift] click a menu slot (0-35 inventory, 300+ container, 600+ station, 650 result)",
|
||||
"click-gui X Y [right] [shift] click at GUI pixel X Y relative to the menu's top-left corner",
|
||||
"book [next|back|take|done] the open book or lectern: its page, or press one of its buttons",
|
||||
"type TEXT | type-back type into the anvil name box / delete one character",
|
||||
"enchant ID LEVEL enchant the held item (books store the enchantment)",
|
||||
"potion ITEM POTION give a potion item (potion, splash_potion, lingering_potion, tipped_arrow)",
|
||||
@@ -136,6 +137,7 @@ impl Game<'_> {
|
||||
self.apply_equipment();
|
||||
ok()
|
||||
}
|
||||
"book" => Ok(Some(self.book_control(words.get(1).copied())?)),
|
||||
"potion" => {
|
||||
let stack = ItemStack::new(&id(arg(1)?), 1).with_potion(&id(arg(2)?));
|
||||
let rest = self.give(stack);
|
||||
|
||||
@@ -13,7 +13,6 @@ use horizon_client::{
|
||||
stations::{self, AnvilResult, EnchantOffers},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Slot ids of the open station: inputs from 600, the result at 650.
|
||||
pub(super) const INPUT: usize = 600;
|
||||
@@ -36,7 +35,7 @@ pub(super) enum Kind {
|
||||
Loom,
|
||||
}
|
||||
impl Kind {
|
||||
fn from_block(name: &str) -> Option<Self> {
|
||||
pub(super) fn from_block(name: &str) -> Option<Self> {
|
||||
Some(match name {
|
||||
"minecraft:enchanting_table" => Self::Enchanting,
|
||||
"minecraft:anvil" | "minecraft:chipped_anvil" | "minecraft:damaged_anvil" => {
|
||||
@@ -51,7 +50,7 @@ impl Kind {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn inputs(self) -> usize {
|
||||
pub(super) fn inputs(self) -> usize {
|
||||
match self {
|
||||
Self::Smithing | Self::Loom => 3,
|
||||
Self::Stonecutter | Self::Beacon => 1,
|
||||
@@ -71,7 +70,7 @@ impl Kind {
|
||||
Self::Loom => "gui/container/loom",
|
||||
}
|
||||
}
|
||||
fn title_key(self) -> &'static str {
|
||||
pub(super) fn title_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Enchanting => "container.enchant",
|
||||
Self::Anvil => "container.repair",
|
||||
@@ -108,6 +107,10 @@ impl Kind {
|
||||
};
|
||||
table[i.min(table.len() - 1)]
|
||||
}
|
||||
/// Whether the menu has a result slot after its inputs.
|
||||
pub(super) fn has_result(self) -> bool {
|
||||
self.result_pos().is_some()
|
||||
}
|
||||
fn result_pos(self) -> Option<(f32, f32)> {
|
||||
match self {
|
||||
Self::Enchanting => None,
|
||||
@@ -128,18 +131,20 @@ pub(super) struct Station {
|
||||
pub(super) pos: [i32; 3],
|
||||
pub(super) inputs: Vec<Option<ItemStack>>,
|
||||
pub(super) result: Option<ItemStack>,
|
||||
offers: EnchantOffers,
|
||||
pub(super) offers: EnchantOffers,
|
||||
bookcases: i32,
|
||||
anvil: AnvilResult,
|
||||
pub(super) anvil: AnvilResult,
|
||||
/// Anvil rename box text.
|
||||
pub(super) name: String,
|
||||
/// Stonecutter: recipe indices for the input, selection and first shown index.
|
||||
recipes: Vec<usize>,
|
||||
selected: Option<usize>,
|
||||
/// Stonecutter: the menu's recipe list (horizon_client::menu), its
|
||||
/// `selectedRecipeIndex` and the screen's first shown index.
|
||||
pub(super) stone: horizon_client::menu::Stonecutter,
|
||||
pub(super) selected: Option<usize>,
|
||||
start: usize,
|
||||
input_item: Option<Arc<str>>,
|
||||
/// Anvil: the input the rename box was last filled from.
|
||||
anvil_last: Option<ItemStack>,
|
||||
/// Anvil on a server: the name last sent (the client menu's `itemName`).
|
||||
pub(super) anvil_sent: Option<String>,
|
||||
/// When the screen opened: the rename box has the focus from then.
|
||||
opened: std::time::Instant,
|
||||
pub(super) last_sound: u64,
|
||||
@@ -147,6 +152,8 @@ pub(super) struct Station {
|
||||
pub(super) merchant: Option<Box<super::trading::MerchantState>>,
|
||||
/// Beacon menu: the selected primary and secondary power (EFFECTS index).
|
||||
pub(super) beacon: [Option<usize>; 2],
|
||||
/// A server's beacon menu: the pyramid's levels (its data slot 0).
|
||||
pub(super) beacon_levels: Option<i32>,
|
||||
/// The loom's patterns, selection and scroller (game/loom.rs).
|
||||
pub(super) loom: super::loom::LoomState,
|
||||
}
|
||||
@@ -161,21 +168,29 @@ impl Station {
|
||||
bookcases: 0,
|
||||
anvil: AnvilResult::default(),
|
||||
name: String::new(),
|
||||
recipes: Vec::new(),
|
||||
stone: Default::default(),
|
||||
selected: None,
|
||||
start: 0,
|
||||
input_item: None,
|
||||
anvil_last: None,
|
||||
anvil_sent: None,
|
||||
opened: std::time::Instant::now(),
|
||||
last_sound: 0,
|
||||
merchant: None,
|
||||
beacon: [None; 2],
|
||||
beacon_levels: None,
|
||||
loom: Default::default(),
|
||||
}
|
||||
}
|
||||
pub(super) fn new_kind(kind: Kind, pos: [i32; 3]) -> Self {
|
||||
Self::new(kind, pos)
|
||||
}
|
||||
/// `StonecutterScreen.containerChanged`: without recipes to show, the
|
||||
/// list scrolls back to the top.
|
||||
pub(super) fn stonecutter_changed(&mut self) {
|
||||
if self.inputs[0].is_none() || self.stone.recipes.is_empty() {
|
||||
self.start = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 108x19 enchantment button `i` (relative to the GUI origin).
|
||||
@@ -323,6 +338,10 @@ impl Game<'_> {
|
||||
let Some(mut station) = self.station.take() else {
|
||||
return;
|
||||
};
|
||||
if self.remote.is_some() && station.kind != Kind::Merchant {
|
||||
// A server's workstation: its removed() gives the inputs back.
|
||||
return;
|
||||
}
|
||||
if let Some(m) = station.merchant.as_mut() {
|
||||
self.merchant_closed(m);
|
||||
}
|
||||
@@ -375,7 +394,7 @@ impl Game<'_> {
|
||||
_ => &mut self.scratch_slot,
|
||||
}
|
||||
}
|
||||
fn count_bookshelves(&self, pos: [i32; 3]) -> i32 {
|
||||
pub(super) fn count_bookshelves(&self, pos: [i32; 3]) -> i32 {
|
||||
let tags = &self.res.items().block_tags;
|
||||
let is = |p: [i32; 3], tag: &str| {
|
||||
let name = &self
|
||||
@@ -399,7 +418,7 @@ impl Game<'_> {
|
||||
.count() as i32
|
||||
}
|
||||
/// The anvil name the menu works with (`AnvilScreen.onNameChanged`).
|
||||
fn anvil_item_name(&self, s: &Station) -> String {
|
||||
pub(super) fn anvil_item_name(&self, s: &Station) -> String {
|
||||
match &s.inputs[0] {
|
||||
Some(input)
|
||||
if input.custom_name().is_none() && s.name == self.pack.names.item(&input.item) =>
|
||||
@@ -419,6 +438,16 @@ impl Game<'_> {
|
||||
let data = self.res.stations();
|
||||
let items = self.res.items();
|
||||
match s.kind {
|
||||
// A server's table: its data slots carry the offers, and its
|
||||
// client menu only clears them without an enchantable item.
|
||||
Kind::Enchanting if self.remote.is_some() => {
|
||||
let enchantable = s.inputs[0].as_ref().is_some_and(|i| {
|
||||
data.enchantments.enchantable(&i.item) > 0 && i.enchantments().is_empty()
|
||||
});
|
||||
if !enchantable {
|
||||
s.offers = Default::default();
|
||||
}
|
||||
}
|
||||
Kind::Enchanting => {
|
||||
s.bookcases = self.count_bookshelves(s.pos);
|
||||
s.offers = stations::enchant_offers(
|
||||
@@ -465,35 +494,9 @@ impl Game<'_> {
|
||||
// The menu engine keeps the merchant's result (Game::menu_session).
|
||||
Kind::Merchant => {}
|
||||
Kind::Beacon => {}
|
||||
Kind::Cartography => s.result = self.cartography_result(&s),
|
||||
// The menu engine keeps the loom's state (Game::menu_session).
|
||||
Kind::Loom => {}
|
||||
Kind::Stonecutter => {
|
||||
let item = s.inputs[0].as_ref().map(|i| i.item.clone());
|
||||
if item != s.input_item {
|
||||
s.input_item = item;
|
||||
s.selected = None;
|
||||
s.start = 0;
|
||||
s.recipes = match &s.inputs[0] {
|
||||
Some(input) => data
|
||||
.recipes
|
||||
.stonecutting
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.input.test(input))
|
||||
.map(|(i, _)| i)
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
}
|
||||
s.result = match (s.selected, &s.inputs[0]) {
|
||||
(Some(i), Some(_)) => s
|
||||
.recipes
|
||||
.get(i)
|
||||
.map(|&r| data.recipes.stonecutting[r].result.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// The menu engine keeps the cartography table's, the loom's and
|
||||
// the stonecutter's state (Game::menu_session).
|
||||
Kind::Cartography | Kind::Loom | Kind::Stonecutter => {}
|
||||
}
|
||||
self.station = Some(s);
|
||||
}
|
||||
@@ -505,7 +508,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Slot.mayPlace` and `getMaxStackSize` for station slots.
|
||||
fn slot_rules(&self, slot: usize, stack: &ItemStack) -> (bool, u32) {
|
||||
let data = self.data();
|
||||
if slot >= super::mount_menu::SADDLE {
|
||||
return self.mount_slot_rules(slot, stack);
|
||||
}
|
||||
@@ -528,24 +530,7 @@ impl Game<'_> {
|
||||
if slot == RESULT {
|
||||
return (false, 64);
|
||||
}
|
||||
let i = slot - INPUT;
|
||||
match s.kind {
|
||||
Kind::Enchanting if i == 0 => (true, 1),
|
||||
Kind::Beacon => (super::beacon::is_payment(&stack.item), 1),
|
||||
Kind::Loom => (false, 64),
|
||||
Kind::Cartography if i == 0 => (super::maps::map_id(stack).is_some(), 64),
|
||||
Kind::Cartography => (
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:paper" | "minecraft:map" | "minecraft:glass_pane"
|
||||
),
|
||||
64,
|
||||
),
|
||||
Kind::Enchanting => (&*stack.item == "minecraft:lapis_lazuli", 64),
|
||||
Kind::Grindstone => (stations::grindstone_accepts(self.res.items(), stack), 64),
|
||||
Kind::Smithing => (data.recipes.smithing_accepts(i, stack), 64),
|
||||
_ => (true, 64),
|
||||
}
|
||||
(s.kind != Kind::Loom, 64)
|
||||
}
|
||||
/// `AbstractContainerMenu.doClick` PICKUP on a filtered slot.
|
||||
pub(super) fn click_filtered(&mut self, slot: usize, right: bool) {
|
||||
@@ -644,112 +629,80 @@ impl Game<'_> {
|
||||
}
|
||||
stack.count != start
|
||||
}
|
||||
/// The player inventory in menu order: main rows, then the hotbar.
|
||||
fn player_slots() -> Vec<usize> {
|
||||
(9..36).chain(0..9).collect()
|
||||
/// After an anvil or smithing click on the menu engine: the anvil's cost
|
||||
/// and state back into the station and the rename box following the
|
||||
/// input (`AnvilScreen.slotChanged`), then the rest of a taken result's
|
||||
/// `onTake` (the levels, the anvil's wear, the use sound) unless a
|
||||
/// server does it.
|
||||
pub(super) fn combiner_session_end(
|
||||
&mut self,
|
||||
menu: &horizon_client::menu::Menu,
|
||||
predicting: bool,
|
||||
) {
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let (pos, kind) = (s.pos, s.kind);
|
||||
if kind == Kind::Anvil {
|
||||
s.anvil.cost = menu.data[0];
|
||||
s.anvil.repair_item_count_cost = menu.anvil.repair_count;
|
||||
s.anvil.only_renaming = menu.anvil.only_renaming;
|
||||
s.anvil.result = s.result.clone();
|
||||
}
|
||||
self.station_after_input();
|
||||
if !menu.combined || predicting {
|
||||
return;
|
||||
}
|
||||
if kind == Kind::Smithing {
|
||||
self.level_event_at(1044, pos, 0);
|
||||
return;
|
||||
}
|
||||
self.give_levels(menu.level_change);
|
||||
if self.anvil_used(pos, self.creative) {
|
||||
self.close_station();
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// A shift-click: `AbstractContainerMenu.doClick`'s QUICK_MOVE repeats
|
||||
/// `quickMoveStack` while it moves something and the slot still holds the
|
||||
/// same item.
|
||||
fn quick_move(&mut self, slot: usize) {
|
||||
while let Some(item) = self.slot_mut(slot).as_ref().map(|s| s.item.clone())
|
||||
&& self.quick_move_once(slot)
|
||||
&& self.slot_mut(slot).as_ref().is_some_and(|s| s.item == item)
|
||||
{}
|
||||
}
|
||||
/// The station menu's `quickMoveStack` for one slot (`EnchantmentMenu`,
|
||||
/// `BeaconMenu`, `GrindstoneMenu`, `StonecutterMenu`,
|
||||
/// `CartographyTableMenu`, and `ItemCombinerMenu` with `SmithingMenu`'s
|
||||
/// `canMoveIntoInputSlots`); whether the stack's count changed. Each
|
||||
/// rule that names its target slots moves nowhere else when they are full.
|
||||
fn quick_move_once(&mut self, slot: usize) -> bool {
|
||||
let Some(kind) = self.station.as_ref().map(|s| s.kind) else {
|
||||
return false;
|
||||
};
|
||||
let Some(mut stack) = self.slot_mut(slot).take() else {
|
||||
return false;
|
||||
};
|
||||
let start = stack.count;
|
||||
let empty = |game: &mut Self, i: usize| game.station_slot_mut(INPUT + i).is_none();
|
||||
// The other half of the inventory: main rows to the hotbar and back.
|
||||
let halves: Vec<usize> = if slot < 9 {
|
||||
(9..36).collect()
|
||||
} else {
|
||||
(0..9).collect()
|
||||
};
|
||||
if slot >= INPUT {
|
||||
// An input slot: into the inventory, the hotbar's last slot first
|
||||
// where vanilla moves in reverse.
|
||||
let reverse = matches!(kind, Kind::Enchanting | Kind::Beacon);
|
||||
self.move_to(&mut stack, &Self::player_slots(), reverse);
|
||||
} else {
|
||||
match kind {
|
||||
Kind::Enchanting => {
|
||||
if &*stack.item == "minecraft:lapis_lazuli" {
|
||||
self.move_to(&mut stack, &[INPUT + 1], true);
|
||||
} else if empty(self, 0) {
|
||||
*self.station_slot_mut(INPUT) = Some(ItemStack {
|
||||
count: 1,
|
||||
..stack.clone()
|
||||
});
|
||||
stack.count -= 1;
|
||||
}
|
||||
/// `AnvilMenu.onTake`'s level part: out of creative a 12% chance
|
||||
/// (`AnvilBlock.damage`) to wear the anvil a stage, or break a damaged one
|
||||
/// (level event 1029); otherwise level event 1030. True when it broke.
|
||||
pub(super) fn anvil_used(&mut self, pos: [i32; 3], creative: bool) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let anvil = matches!(
|
||||
&*name,
|
||||
"minecraft:anvil" | "minecraft:chipped_anvil" | "minecraft:damaged_anvil"
|
||||
);
|
||||
if !creative && anvil && self.world.random.next_float() < 0.12 {
|
||||
match &*name {
|
||||
"minecraft:anvil" => {
|
||||
let _ = self.set_state_named(pos, "minecraft:chipped_anvil", &["facing"]);
|
||||
}
|
||||
Kind::Beacon => {
|
||||
if empty(self, 0) && super::beacon::is_payment(&stack.item) && stack.count == 1
|
||||
{
|
||||
self.move_to(&mut stack, &[INPUT], false);
|
||||
} else {
|
||||
self.move_to(&mut stack, &halves, false);
|
||||
}
|
||||
"minecraft:chipped_anvil" => {
|
||||
let _ = self.set_state_named(pos, "minecraft:damaged_anvil", &["facing"]);
|
||||
}
|
||||
Kind::Grindstone => {
|
||||
if empty(self, 0) || empty(self, 1) {
|
||||
self.move_to(&mut stack, &[INPUT, INPUT + 1], false);
|
||||
} else {
|
||||
self.move_to(&mut stack, &halves, false);
|
||||
}
|
||||
_ => {
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], self.world.air);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.level_event_at(1029, pos, 0);
|
||||
return true;
|
||||
}
|
||||
Kind::Stonecutter => {
|
||||
if self.data().recipes.stonecutter(&stack).is_empty() {
|
||||
self.move_to(&mut stack, &halves, false);
|
||||
} else {
|
||||
self.move_to(&mut stack, &[INPUT], false);
|
||||
}
|
||||
}
|
||||
Kind::Cartography => {
|
||||
if super::maps::map_id(&stack).is_some() {
|
||||
self.move_to(&mut stack, &[INPUT], false);
|
||||
} else if matches!(
|
||||
&*stack.item,
|
||||
"minecraft:paper" | "minecraft:map" | "minecraft:glass_pane"
|
||||
) {
|
||||
self.move_to(&mut stack, &[INPUT + 1], false);
|
||||
} else {
|
||||
self.move_to(&mut stack, &halves, false);
|
||||
}
|
||||
}
|
||||
Kind::Anvil | Kind::Smithing => {
|
||||
let inputs: Vec<usize> = (0..kind.inputs()).map(|i| INPUT + i).collect();
|
||||
// ItemCombinerMenu.canMoveIntoInputSlots: always for the
|
||||
// anvil; the smithing table needs an empty slot whose
|
||||
// ingredient test takes the stack.
|
||||
let into_inputs = kind == Kind::Anvil
|
||||
|| (0..3).any(|i| {
|
||||
empty(self, i) && self.data().recipes.smithing_accepts(i, &stack)
|
||||
});
|
||||
let targets = if into_inputs { &inputs } else { &halves };
|
||||
self.move_to(&mut stack, targets, false);
|
||||
}
|
||||
// Their clicks run on the menu engine.
|
||||
Kind::Merchant | Kind::Loom => {}
|
||||
}
|
||||
}
|
||||
let moved = stack.count != start;
|
||||
if stack.count > 0 {
|
||||
*self.slot_mut(slot) = Some(stack);
|
||||
self.level_event_at(1030, pos, 0);
|
||||
false
|
||||
}
|
||||
/// The grindstone result's `onTake` with a level (`amount`: the menu's
|
||||
/// `getExperienceAmount` sum): the orbs at the block's centre
|
||||
/// (`ExperienceOrb.award`), then level event 1042, the grindstone's sound
|
||||
/// for everyone around.
|
||||
pub(super) fn grindstone_taken(&mut self, pos: [i32; 3], amount: i32) {
|
||||
let xp = stations::grindstone_xp_roll(amount, &mut self.world.random);
|
||||
if xp > 0 {
|
||||
self.spawn_orbs(pos.map(|v| f64::from(v) + 0.5), xp);
|
||||
}
|
||||
moved
|
||||
self.level_event_at(1042, pos, 0);
|
||||
}
|
||||
fn play_at(&mut self, sound: &str, pos: [i32; 3], volume: f32, pitch: f32) {
|
||||
let at = [
|
||||
@@ -788,185 +741,6 @@ impl Game<'_> {
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// The result slot's `onTake`.
|
||||
fn take_result(&mut self) {
|
||||
let Some(mut s) = self.station.take() else {
|
||||
return;
|
||||
};
|
||||
let pos = s.pos;
|
||||
match s.kind {
|
||||
Kind::Anvil => {
|
||||
let cost = s.anvil.cost;
|
||||
let repair = s.anvil.repair_item_count_cost;
|
||||
if repair > 0 {
|
||||
if let Some(a) = s.inputs[1].as_mut()
|
||||
&& a.count > repair
|
||||
{
|
||||
a.count -= repair;
|
||||
} else {
|
||||
s.inputs[1] = None;
|
||||
}
|
||||
} else if !s.anvil.only_renaming {
|
||||
s.inputs[1] = None;
|
||||
}
|
||||
s.inputs[0] = None;
|
||||
s.name.clear();
|
||||
if !self.creative {
|
||||
self.give_levels(-cost);
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
if !self.creative && self.world.random.next_float() < 0.12 {
|
||||
// AnvilBlock.damage
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let next = match &*name {
|
||||
"minecraft:anvil" => Some("minecraft:chipped_anvil"),
|
||||
"minecraft:chipped_anvil" => Some("minecraft:damaged_anvil"),
|
||||
_ => None,
|
||||
};
|
||||
match next {
|
||||
Some(next) => {
|
||||
let _ = self.set_state_named(pos, next, &["facing"]);
|
||||
self.play_at("minecraft:block.anvil.use", pos, 1.0, pitch);
|
||||
}
|
||||
None => {
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], self.world.air);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.play_at("minecraft:block.anvil.destroy", pos, 1.0, pitch);
|
||||
self.station = Some(s);
|
||||
self.close_station();
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.play_at("minecraft:block.anvil.use", pos, 1.0, pitch);
|
||||
}
|
||||
}
|
||||
Kind::Grindstone => {
|
||||
let (a, b) = (s.inputs[0].take(), s.inputs[1].take());
|
||||
let mut random = LegacyRandom::new(self.world.random.next_long());
|
||||
let xp = stations::grindstone_experience(
|
||||
&self.res.stations().enchantments,
|
||||
&mut random,
|
||||
a.as_ref(),
|
||||
b.as_ref(),
|
||||
);
|
||||
if xp > 0 {
|
||||
self.spawn_orbs(
|
||||
[
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
xp,
|
||||
);
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.play_at("minecraft:block.grindstone.use", pos, 1.0, pitch);
|
||||
}
|
||||
Kind::Smithing => {
|
||||
for slot in s.inputs.iter_mut() {
|
||||
if let Some(x) = slot.as_mut() {
|
||||
x.count -= 1;
|
||||
if x.count == 0 {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.play_at("minecraft:block.smithing_table.use", pos, 1.0, pitch);
|
||||
}
|
||||
Kind::Stonecutter => {
|
||||
if let Some(x) = s.inputs[0].as_mut() {
|
||||
x.count -= 1;
|
||||
if x.count == 0 {
|
||||
s.inputs[0] = None;
|
||||
}
|
||||
}
|
||||
if s.last_sound != self.ticks {
|
||||
s.last_sound = self.ticks;
|
||||
self.play_at("minecraft:ui.stonecutter.take_result", pos, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
Kind::Merchant => {}
|
||||
Kind::Beacon => {}
|
||||
Kind::Cartography => {
|
||||
for slot in &mut s.inputs {
|
||||
if let Some(x) = slot.as_mut() {
|
||||
x.count -= 1;
|
||||
if x.count == 0 {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.last_sound != self.ticks {
|
||||
s.last_sound = self.ticks;
|
||||
self.cartography_taken(pos);
|
||||
}
|
||||
}
|
||||
Kind::Loom => {}
|
||||
Kind::Enchanting => {}
|
||||
}
|
||||
self.station = Some(s);
|
||||
self.station_changed();
|
||||
}
|
||||
fn click_result(&mut self, shift: bool) {
|
||||
loop {
|
||||
let Some(result) = self.station.as_ref().and_then(|s| s.result.clone()) else {
|
||||
return;
|
||||
};
|
||||
if !self.may_take_result() {
|
||||
return;
|
||||
}
|
||||
let result = if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.kind == Kind::Cartography)
|
||||
{
|
||||
self.cartography_post(result)
|
||||
} else {
|
||||
result
|
||||
};
|
||||
if shift {
|
||||
let mut stack = result.clone();
|
||||
let player = Self::player_slots();
|
||||
// Simulate first so nothing is lost when the inventory is full.
|
||||
let saved: Vec<Option<ItemStack>> = self.inventory.clone();
|
||||
self.move_to(&mut stack, &player, true);
|
||||
if stack.count > 0 {
|
||||
self.inventory = saved;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
match &mut self.carried {
|
||||
None => self.carried = Some(result.clone()),
|
||||
Some(c)
|
||||
if c.same_item_same_components(&result)
|
||||
&& c.count + result.count
|
||||
<= self.res.items().get(&c.item).map_or(64, |i| i.max_stack) =>
|
||||
{
|
||||
c.count += result.count
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
if let Some(s) = self.station.as_mut() {
|
||||
s.result = None;
|
||||
}
|
||||
self.take_result();
|
||||
let again = shift
|
||||
&& self
|
||||
.station
|
||||
.as_ref()
|
||||
.and_then(|s| s.result.as_ref())
|
||||
.is_some_and(|r| r.item == result.item);
|
||||
if !again {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EnchantmentMenu.clickMenuButton`.
|
||||
fn click_enchant(&mut self, button: usize) {
|
||||
let Some(s) = self.station.as_ref() else {
|
||||
@@ -987,6 +761,11 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
// EnchantmentScreen.mouseClicked: the client menu took it, the server enchants.
|
||||
self.remote_station_button(button as i32);
|
||||
return;
|
||||
}
|
||||
let Some(enchanted) = stations::apply_enchantment(
|
||||
&self.res.stations().enchantments,
|
||||
self.res.items(),
|
||||
@@ -1036,7 +815,7 @@ impl Game<'_> {
|
||||
})
|
||||
.collect(),
|
||||
Kind::Stonecutter if s.inputs[0].is_some() => (s.start
|
||||
..(s.start + 12).min(s.recipes.len()))
|
||||
..(s.start + 12).min(s.stone.recipes.len()))
|
||||
.map(|index| {
|
||||
let p = index - s.start;
|
||||
[
|
||||
@@ -1089,64 +868,21 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == Kind::Stonecutter
|
||||
&& let Some(s) = self.station.as_ref()
|
||||
&& s.inputs[0].is_some()
|
||||
{
|
||||
for index in s.start..(s.start + 12).min(s.recipes.len()) {
|
||||
let p = index - s.start;
|
||||
let (x, y) = (52.0 + (p % 4) as f32 * 16.0, 14.0 + (p / 4) as f32 * 18.0);
|
||||
if inside(local, x, y, 16.0, 18.0) {
|
||||
if s.selected != Some(index) {
|
||||
if let Some(st) = self.station.as_mut() {
|
||||
st.selected = Some(index);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:ui.stonecutter.select_recipe",
|
||||
None,
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
self.station_changed();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if kind == Kind::Stonecutter && self.stonecutter_click(local) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
|
||||
return true;
|
||||
};
|
||||
if self
|
||||
.station
|
||||
.as_ref()
|
||||
.is_none_or(|s| matches!(s.kind, Kind::Loom | Kind::Merchant))
|
||||
{
|
||||
// LoomMenu, MerchantMenu and the brewing stand's BrewingStandMenu
|
||||
// clicks run on the verified menu engine.
|
||||
let input = if shift {
|
||||
horizon_client::menu::Input::QuickMove
|
||||
} else {
|
||||
horizon_client::menu::Input::Pickup
|
||||
};
|
||||
self.menu_click(Some(slot), i32::from(right), input);
|
||||
return true;
|
||||
}
|
||||
if slot == RESULT {
|
||||
self.click_result(shift);
|
||||
return true;
|
||||
}
|
||||
if shift {
|
||||
self.quick_move(slot);
|
||||
} else if slot >= INPUT {
|
||||
self.click_filtered(slot, right);
|
||||
// Every station's clicks (and the brewing stand's) run on the
|
||||
// verified menu engine.
|
||||
let input = if shift {
|
||||
horizon_client::menu::Input::QuickMove
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if let Some(key) = self.container {
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
self.station_after_input();
|
||||
horizon_client::menu::Input::Pickup
|
||||
};
|
||||
self.menu_click(Some(slot), i32::from(right), input);
|
||||
true
|
||||
}
|
||||
/// Recompute after slot changes; the anvil box follows its input item.
|
||||
@@ -1164,16 +900,49 @@ impl Game<'_> {
|
||||
}
|
||||
self.station_changed();
|
||||
}
|
||||
/// `StonecutterScreen.mouseClicked` on its recipe grid: `clickMenuButton`
|
||||
/// with the cell's index (an empty cell's too), and the select sound when
|
||||
/// the menu took it.
|
||||
fn stonecutter_click(&mut self, local: (f32, f32)) -> bool {
|
||||
let Some(start) = self
|
||||
.station
|
||||
.as_ref()
|
||||
.filter(|s| s.inputs[0].is_some() && !s.stone.recipes.is_empty())
|
||||
.map(|s| s.start)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
for index in start..start + 12 {
|
||||
let p = index - start;
|
||||
let (x, y) = (52.0 + (p % 4) as f32 * 16.0, 14.0 + (p / 4) as f32 * 18.0);
|
||||
if !inside(local, x, y, 16.0, 18.0) {
|
||||
continue;
|
||||
}
|
||||
let mut taken = false;
|
||||
let flag = &mut taken;
|
||||
self.menu_session(|menu, env, _| {
|
||||
*flag = menu.button(env, index as i32);
|
||||
Some(*flag)
|
||||
});
|
||||
if taken {
|
||||
self.audio
|
||||
.play("minecraft:ui.stonecutter.select_recipe", None, 1.0, 1.0);
|
||||
self.remote_station_button(index as i32);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub(super) fn station_scroll(&mut self, d: f32) {
|
||||
self.merchant_scroll(d);
|
||||
self.loom_scroll(d);
|
||||
let Some(s) = self.station.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if s.kind != Kind::Stonecutter || s.recipes.len() <= 12 {
|
||||
if s.kind != Kind::Stonecutter || s.stone.recipes.len() <= 12 {
|
||||
return;
|
||||
}
|
||||
let rows = s.recipes.len().div_ceil(4) - 3;
|
||||
let rows = s.stone.recipes.len().div_ceil(4) - 3;
|
||||
let first_row = (s.start / 4) as i32 - d.signum() as i32;
|
||||
s.start = first_row.clamp(0, rows as i32) as usize * 4;
|
||||
}
|
||||
@@ -1207,6 +976,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
/// Ctrl+A/C/V/X in the anvil's rename box (an `EditBox` of 50) or the
|
||||
/// recipe book's search box.
|
||||
@@ -1227,6 +997,7 @@ impl Game<'_> {
|
||||
ANVIL_NAME_CHARS,
|
||||
) {
|
||||
self.station_changed();
|
||||
self.remote_anvil_rename();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1740,9 +1511,9 @@ impl Game<'_> {
|
||||
}
|
||||
Kind::Merchant | Kind::Beacon | Kind::Cartography | Kind::Loom => {}
|
||||
Kind::Stonecutter => {
|
||||
let display = s.inputs[0].is_some() && !s.recipes.is_empty();
|
||||
let active = display && s.recipes.len() > 12;
|
||||
let rows = s.recipes.len().div_ceil(4).saturating_sub(3).max(1);
|
||||
let display = s.inputs[0].is_some() && !s.stone.recipes.is_empty();
|
||||
let active = display && s.stone.recipes.len() > 12;
|
||||
let rows = s.stone.recipes.len().div_ceil(4).saturating_sub(3).max(1);
|
||||
let offs = if active {
|
||||
(s.start / 4) as f32 / rows as f32
|
||||
} else {
|
||||
@@ -1763,13 +1534,14 @@ impl Game<'_> {
|
||||
let (start, selected) = (s.start, s.selected);
|
||||
// graphics.item: the icon only, without the count.
|
||||
let shown: Vec<(usize, ItemStack)> = (start
|
||||
..(start + 12).min(s.recipes.len()))
|
||||
..(start + 12).min(s.stone.recipes.len()))
|
||||
.map(|i| {
|
||||
(
|
||||
i,
|
||||
ItemStack {
|
||||
count: 1,
|
||||
..self.res.stations().recipes.stonecutting[s.recipes[i]]
|
||||
..self.res.stations().recipes.stonecutting
|
||||
[s.stone.recipes[i]]
|
||||
.result
|
||||
.clone()
|
||||
},
|
||||
@@ -2082,7 +1854,7 @@ impl Game<'_> {
|
||||
"bookcases": s.bookcases,
|
||||
"anvil_cost": s.anvil.cost,
|
||||
"result": s.result.as_ref().map(|r| format!("{} x{}", r.item, r.count)),
|
||||
"recipes": s.recipes.iter().map(|&i| recipes[i].id.clone()).collect::<Vec<_>>(),
|
||||
"recipes": s.stone.recipes.iter().map(|&i| recipes[i].id.clone()).collect::<Vec<_>>(),
|
||||
"selected": s.selected,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,6 +122,38 @@ impl Game<'_> {
|
||||
let sky = self.world.light(x, y + 1, z) >> 4 == 15;
|
||||
water || (self.world.weather.raining && sky)
|
||||
}
|
||||
/// The living entities a spinning player can touch this tick, for
|
||||
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
|
||||
pub(super) fn spin_targets(&self) -> Vec<(u32, Aabb)> {
|
||||
let Some(p) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| p.auto_spin_attack_ticks > 0)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// A spin moves a few blocks a tick at most.
|
||||
let near = p.bounds().inflate(Vec3::new(16.0, 16.0, 16.0));
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && m.bounds().intersects(near))
|
||||
.map(|m| (m.id, m.bounds()))
|
||||
.collect()
|
||||
}
|
||||
/// `Player.doAutoAttackOnTouch`: the touched entity takes `Player.attack`
|
||||
/// with the spin's damage; once the spin is over its damage is cleared.
|
||||
pub(super) fn end_spin_attack(&mut self, hit: Option<u32>) {
|
||||
if let Some(id) = hit
|
||||
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
|
||||
{
|
||||
self.attack_mob(index);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& p.auto_spin_attack_ticks <= 0
|
||||
{
|
||||
p.auto_spin_attack_dmg = 0.0;
|
||||
}
|
||||
}
|
||||
/// `TridentItem.releaseUsing`.
|
||||
pub(super) fn release_trident(&mut self, ticks: u32) {
|
||||
if ticks < 10 {
|
||||
@@ -152,6 +184,9 @@ impl Game<'_> {
|
||||
p.velocity.y + d[1] * f64::from(strength),
|
||||
p.velocity.z + d[2] * f64::from(strength),
|
||||
);
|
||||
// Player.startAutoSpinAttack(20, 8, stack).
|
||||
p.auto_spin_attack_ticks = 20;
|
||||
p.auto_spin_attack_dmg = 8.0;
|
||||
if p.ground.on_ground {
|
||||
p.position.y += 1.2;
|
||||
}
|
||||
|
||||
@@ -4167,6 +4167,34 @@ mod tests {
|
||||
);
|
||||
assert_eq!(surface_format(&[Rgba16Float], Bgra8UnormSrgb), Rgba16Float);
|
||||
}
|
||||
/// A Wayland surface on a compositor with colour management, as wgpu lists it (Mesa 26.2
|
||||
/// ANV on an Intel HD 620 under weston 16): wide formats first. The pre-#1075 choice,
|
||||
/// the first non-sRGB format, took `Rgba16Float` and the window looked washed out, as if
|
||||
/// under a white overlay (#1007); XWayland lists no such format, which is why it looked fine.
|
||||
#[test]
|
||||
fn a_colour_managed_wayland_surface_still_gets_8_bit() {
|
||||
use super::surface_format;
|
||||
use wgpu::TextureFormat::*;
|
||||
let wayland = [
|
||||
Rgba16Float,
|
||||
Rgba16Unorm,
|
||||
Rgb10a2Unorm,
|
||||
Bgra8UnormSrgb,
|
||||
Bgra8Unorm,
|
||||
Rgba8UnormSrgb,
|
||||
Rgba8Unorm,
|
||||
];
|
||||
assert_eq!(surface_format(&wayland, Bgra8UnormSrgb), Bgra8Unorm);
|
||||
// Without Bgra8: Rgba8, still not a 16- or 10-bit format.
|
||||
let without_bgra = [
|
||||
Rgba16Float,
|
||||
Rgba16Unorm,
|
||||
Rgb10a2Unorm,
|
||||
Rgba8UnormSrgb,
|
||||
Rgba8Unorm,
|
||||
];
|
||||
assert_eq!(surface_format(&without_bgra, Rgba8UnormSrgb), Rgba8Unorm);
|
||||
}
|
||||
#[test]
|
||||
fn special_material_batches_preserve_quad_order_and_offsets() {
|
||||
let quad = |layer, id| {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! The anonymous "I'm playing" ping (`horizon_telemetry::live`, docs/telemetry.md) behind the
|
||||
//! launcher's "N playing now". While the game runs, a thread of its own sends a random session
|
||||
//! ID made here and kept only in memory, the version, the platform and whether a LAN or
|
||||
//! play-with-friends world is open, every minute and at once when that last one changes.
|
||||
//! It sends nothing when there is no server (`HORIZON_TELEMETRY_URL`, none by default) or
|
||||
//! with `HORIZON_NO_HEARTBEAT=1` (automated runs).
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread::Thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use horizon_telemetry::live::{self, Ping};
|
||||
|
||||
/// A world is open to LAN or to friends.
|
||||
static LAN: AtomicBool = AtomicBool::new(false);
|
||||
static PINGER: OnceLock<Thread> = OnceLock::new();
|
||||
|
||||
/// After a failure the next ping waits longer, doubling up to this.
|
||||
const MAX_WAIT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Starts the pings, once. Call when the game itself starts (not a dedicated server, not a
|
||||
/// benchmark or an automated run).
|
||||
pub fn start() {
|
||||
if PINGER.get().is_some() || std::env::var_os("HORIZON_NO_HEARTBEAT").is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(endpoint) = horizon_telemetry::endpoint() else {
|
||||
return;
|
||||
};
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Heartbeat".into())
|
||||
.spawn(move || run(&endpoint));
|
||||
match spawned {
|
||||
Ok(handle) => {
|
||||
let _ = PINGER.set(handle.thread().clone());
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: no live count ping: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a world is open to LAN or friends; a change is sent at once.
|
||||
pub fn set_lan(open: bool) {
|
||||
if LAN.swap(open, Ordering::Relaxed) != open
|
||||
&& let Some(pinger) = PINGER.get()
|
||||
{
|
||||
pinger.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
fn run(endpoint: &str) {
|
||||
let session = live::session_id();
|
||||
let version = crate::telemetry::version();
|
||||
let mut wait = live::INTERVAL;
|
||||
let mut failed = false;
|
||||
loop {
|
||||
let ping = Ping::new(&session, &version, LAN.load(Ordering::Relaxed));
|
||||
match live::send(endpoint, &ping) {
|
||||
Ok(()) => {
|
||||
wait = live::INTERVAL;
|
||||
failed = false;
|
||||
}
|
||||
Err(e) => {
|
||||
// Said once, not every minute the server is away.
|
||||
if !failed {
|
||||
log::info!("Horizon: the live count ping failed ({e}); trying again later");
|
||||
}
|
||||
failed = true;
|
||||
wait = (wait * 2).min(MAX_WAIT);
|
||||
}
|
||||
}
|
||||
// Woken early by `set_lan`; a spurious wake only sends one ping more.
|
||||
std::thread::park_timeout(wait);
|
||||
}
|
||||
}
|
||||
@@ -594,12 +594,8 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some(mode) = d.map_post_processing {
|
||||
entries.push((
|
||||
"minecraft:map_post_processing",
|
||||
Tag::String(if mode == 0 { "lock" } else { "scale" }.into()),
|
||||
));
|
||||
}
|
||||
// minecraft:map_post_processing is transient: ItemStack.CODEC never
|
||||
// saves it (only the network carries it).
|
||||
if !d.charged.is_empty() {
|
||||
entries.push((
|
||||
"minecraft:charged_projectiles",
|
||||
|
||||
@@ -778,7 +778,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
|
||||
std::thread::Builder::new()
|
||||
.name(format!("horizon-lan-out-{conn_id}"))
|
||||
.spawn(move || {
|
||||
// A dedicated server's tick comes first (horizon_threads).
|
||||
// Ticks and frames come first (horizon_threads).
|
||||
horizon_threads::behind_server();
|
||||
write_loop(writer, rx, alive, latency, pending)
|
||||
})
|
||||
|
||||
@@ -455,18 +455,30 @@ impl Hub {
|
||||
let Slots::Block(pos) = m.slots else {
|
||||
return;
|
||||
};
|
||||
// AbstractFurnaceBlockEntity.dataAccess, BrewingStandBlockEntity.dataAccess.
|
||||
// AbstractFurnaceBlockEntity.dataAccess, BrewingStandBlockEntity.dataAccess,
|
||||
// CrafterBlockEntity.containerData.
|
||||
let data = match world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Furnace(f)) => [
|
||||
Some(BlockEntity::Furnace(f)) => vec![
|
||||
f.lit_remaining,
|
||||
f.lit_total,
|
||||
f.cooking_timer,
|
||||
f.cooking_total,
|
||||
],
|
||||
Some(BlockEntity::Brewing(b)) => [b.brew_time, b.fuel, b.total_brew_time, b.total_fuel],
|
||||
Some(BlockEntity::Brewing(b)) => {
|
||||
vec![b.brew_time, b.fuel, b.total_brew_time, b.total_fuel]
|
||||
}
|
||||
// CrafterBlockEntity.containerData: the disabled slots, then triggered.
|
||||
Some(BlockEntity::Crafter(c)) => c
|
||||
.disabled
|
||||
.iter()
|
||||
.chain([&c.triggered])
|
||||
.map(|&d| i32::from(d))
|
||||
.collect(),
|
||||
_ => return,
|
||||
}
|
||||
.map(|v| v as i16);
|
||||
.into_iter()
|
||||
.map(|v| v as i16)
|
||||
.collect::<Vec<i16>>();
|
||||
m.sent_data.resize(data.len(), i16::MIN);
|
||||
let window = m.window;
|
||||
let changed: Vec<(usize, i16)> = data
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn changed_attributes(
|
||||
}
|
||||
|
||||
/// What the game says about one entity this tick.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct EntityState {
|
||||
pub id: i32,
|
||||
pub uuid: u128,
|
||||
@@ -61,6 +61,72 @@ pub struct EntityState {
|
||||
pub precise: bool,
|
||||
}
|
||||
|
||||
impl EntityState {
|
||||
/// The entity `id` of type `kind` as a fresh state has it: at the origin,
|
||||
/// at rest, with no data, equipment or attributes. Keeps the allocations
|
||||
/// of the strings and vectors it held.
|
||||
pub fn reset(&mut self, id: i32, uuid: u128, kind: &str) {
|
||||
self.id = id;
|
||||
self.uuid = uuid;
|
||||
self.kind.clear();
|
||||
self.kind.push_str(kind);
|
||||
self.position = [0.0; 3];
|
||||
(self.yaw, self.pitch, self.head_yaw) = (0.0, 0.0, 0.0);
|
||||
self.on_ground = false;
|
||||
self.velocity = [0.0; 3];
|
||||
self.data = 0;
|
||||
self.meta.clear();
|
||||
self.equipment.clear();
|
||||
self.attributes.clear();
|
||||
self.precise = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The states a level describes its entities with, kept from tick to tick:
|
||||
/// each tick's are written over the last tick's in place, so the strings and
|
||||
/// vectors in them are reused instead of being made and freed again for every
|
||||
/// entity on every tick.
|
||||
#[derive(Default)]
|
||||
pub struct States {
|
||||
list: Vec<EntityState>,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl States {
|
||||
/// Start a new tick's states.
|
||||
pub fn clear(&mut self) {
|
||||
self.len = 0;
|
||||
}
|
||||
/// The next entity's state, reset (`EntityState::reset`).
|
||||
pub fn add(&mut self, id: i32, uuid: u128, kind: &str) -> &mut EntityState {
|
||||
if self.len == self.list.len() {
|
||||
self.list.push(EntityState::default());
|
||||
}
|
||||
let e = &mut self.list[self.len];
|
||||
self.len += 1;
|
||||
e.reset(id, uuid, kind);
|
||||
e
|
||||
}
|
||||
pub fn push(&mut self, e: EntityState) {
|
||||
match self.list.get_mut(self.len) {
|
||||
Some(slot) => *slot = e,
|
||||
None => self.list.push(e),
|
||||
}
|
||||
self.len += 1;
|
||||
}
|
||||
pub fn as_slice(&self) -> &[EntityState] {
|
||||
&self.list[..self.len]
|
||||
}
|
||||
}
|
||||
|
||||
impl Extend<EntityState> for States {
|
||||
fn extend<I: IntoIterator<Item = EntityState>>(&mut self, states: I) {
|
||||
for e in states {
|
||||
self.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractArrow`: every update sends its position and rotation.
|
||||
fn is_arrow(kind: &str) -> bool {
|
||||
matches!(
|
||||
@@ -792,6 +858,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_are_written_over_last_ticks_in_place() {
|
||||
let mut states = States::default();
|
||||
let e = states.add(1, 1, "minecraft:zombie");
|
||||
e.position = [1.0, 2.0, 3.0];
|
||||
e.meta.push((9, serializer::FLOAT, Value::Float(20.0)));
|
||||
e.equipment.push((0, None));
|
||||
let (kind, meta) = (e.kind.as_ptr(), e.meta.as_ptr());
|
||||
states.push(state(2.0));
|
||||
states.clear();
|
||||
let e = states.add(3, 3, "minecraft:husk");
|
||||
// Nothing of the last tick's state is left, but its allocations are.
|
||||
let fresh = EntityState {
|
||||
id: 3,
|
||||
uuid: 3,
|
||||
kind: "minecraft:husk".into(),
|
||||
..EntityState::default()
|
||||
};
|
||||
assert_eq!(*e, fresh);
|
||||
assert_eq!((e.kind.as_ptr(), e.meta.as_ptr()), (kind, meta));
|
||||
assert_eq!(states.as_slice(), std::slice::from_ref(&fresh));
|
||||
states.extend([state(5.0), state(6.0)]);
|
||||
assert_eq!(states.as_slice(), [fresh, state(5.0), state(6.0)]);
|
||||
}
|
||||
|
||||
/// The entity as added this tick, then its updates (one a tick).
|
||||
fn added(e: &EntityState) -> ServerEntity {
|
||||
ServerEntity::new(e, Some(100), &Riding::default())
|
||||
|
||||
@@ -147,6 +147,8 @@ pub struct Guest {
|
||||
pub xp_level: i32,
|
||||
pub xp_progress: f32,
|
||||
pub xp_total: i32,
|
||||
/// `Player.enchantmentSeed` (the enchanting table's offers).
|
||||
pub xp_seed: i32,
|
||||
/// Active status effects (`LivingEntity.activeEffects`).
|
||||
pub effects: crate::effects::PlayerEffects,
|
||||
/// What the client was told of each effect: id, amplifier, flags, and
|
||||
@@ -273,6 +275,7 @@ impl Guest {
|
||||
xp_level: 0,
|
||||
xp_progress: 0.0,
|
||||
xp_total: 0,
|
||||
xp_seed: 0,
|
||||
effects: Default::default(),
|
||||
sent_effects: Vec::new(),
|
||||
menu_sent: vec![None; 46],
|
||||
@@ -418,6 +421,43 @@ pub fn stack_to_network(
|
||||
horizon_protocol::item::Component::Int(id),
|
||||
));
|
||||
}
|
||||
if d.repair_cost > 0 {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::REPAIR_COST,
|
||||
horizon_protocol::item::Component::Int(d.repair_cost as i32),
|
||||
));
|
||||
}
|
||||
if let Some(mode) = d.map_post_processing {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::MAP_POST_PROCESSING,
|
||||
horizon_protocol::item::Component::Int(i32::from(mode)),
|
||||
));
|
||||
}
|
||||
// The books a lectern shows and a guest reads.
|
||||
if !d.writable_pages.is_empty() {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::WRITABLE_BOOK_CONTENT,
|
||||
horizon_protocol::item::Component::WritableBook(d.writable_pages.clone()),
|
||||
));
|
||||
}
|
||||
if let Some(w) = &d.written {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::WRITTEN_BOOK_CONTENT,
|
||||
horizon_protocol::item::Component::WrittenBook(
|
||||
horizon_protocol::item::WrittenBook {
|
||||
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(),
|
||||
resolved: true,
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(name) = s.custom_name() {
|
||||
stack.added.push((
|
||||
|
||||
@@ -311,6 +311,11 @@ impl Hub {
|
||||
self.sync.id(registry, name)
|
||||
}
|
||||
|
||||
/// The name of a network id in a built-in registry.
|
||||
pub fn registry_name(&self, registry: &str, id: i32) -> Option<&str> {
|
||||
self.sync.builtin.get(registry)?.name(id)
|
||||
}
|
||||
|
||||
pub fn item_id(&mut self, item: &Arc<str>) -> i32 {
|
||||
if let Some(id) = self.item_ids.get(item) {
|
||||
return *id;
|
||||
|
||||
@@ -66,6 +66,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
guest.xp_level = level.xp_level;
|
||||
guest.xp_progress = level.xp_progress;
|
||||
guest.xp_total = level.xp_total;
|
||||
guest.xp_seed = level.xp_seed;
|
||||
guest.effects = crate::effects::PlayerEffects::from_saved(&level.active_effects);
|
||||
guest.game_mode = level.mode();
|
||||
guest.flying = level.flying;
|
||||
@@ -127,6 +128,7 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.xp_level = guest.xp_level;
|
||||
level.xp_progress = guest.xp_progress;
|
||||
level.xp_total = guest.xp_total;
|
||||
level.xp_seed = guest.xp_seed;
|
||||
level.active_effects = guest.effects.saved();
|
||||
level.game_mode = guest.game_mode;
|
||||
level.creative = guest.game_mode == 1;
|
||||
|
||||
@@ -80,6 +80,7 @@ pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
pub mod graphics_ui;
|
||||
pub mod health;
|
||||
pub mod heartbeat;
|
||||
pub mod hotbar;
|
||||
pub mod hud;
|
||||
pub mod hud_layout;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -690,6 +690,8 @@ pub struct Extra {
|
||||
pub blocking: bool,
|
||||
pub fall_flying: bool,
|
||||
pub fall_fly_ticks: i32,
|
||||
/// `LivingEntity.isAutoSpinAttack` (Riptide).
|
||||
pub auto_spin_attack: bool,
|
||||
pub main_hand_left: bool,
|
||||
pub elytra: ElytraAnimation,
|
||||
pub swim: SwimAnimation,
|
||||
|
||||
@@ -771,6 +771,11 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4
|
||||
.sqrt()
|
||||
.min(1.0);
|
||||
base *= glam::Mat4::from_rotation_z(f * flip_degrees(m).to_radians());
|
||||
} else if m.extra.auto_spin_attack {
|
||||
// Riptide: lying along the look direction, spinning 75 degrees a tick.
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
|
||||
base *= glam::Mat4::from_rotation_x((-90.0 - pitch).to_radians())
|
||||
* glam::Mat4::from_rotation_y(((m.age as f32 + alpha) * -75.0).to_radians());
|
||||
} else if matches!(m.custom_name.as_deref(), Some("Dinnerbone" | "Grumm")) {
|
||||
// isEntityUpsideDown
|
||||
base *= glam::Mat4::from_translation(glam::Vec3::new(0.0, m.height() + 0.1, 0.0))
|
||||
@@ -781,7 +786,9 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4
|
||||
let ticks = m.extra.fall_fly_ticks as f32 + alpha;
|
||||
let scale = (ticks * ticks / 100.0).min(1.0);
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
|
||||
base *= glam::Mat4::from_rotation_x((scale * (-90.0 - pitch)).to_radians());
|
||||
if !m.extra.auto_spin_attack {
|
||||
base *= glam::Mat4::from_rotation_x((scale * (-90.0 - pitch)).to_radians());
|
||||
}
|
||||
let yaw = m.previous_head_yaw + wrap(m.head_yaw - m.previous_head_yaw) * alpha;
|
||||
let (look_x, look_z) = (-(yaw.to_radians()).msin(), (yaw.to_radians()).mcos());
|
||||
let v = m.velocity;
|
||||
|
||||
@@ -691,33 +691,37 @@ pub struct StructureAt {
|
||||
}
|
||||
|
||||
/// `NetherFortressStructure.FORTRESS_ENEMIES`.
|
||||
pub fn fortress_enemies() -> Vec<SpawnEntry> {
|
||||
let e = |entity: &str, weight, min, max, constant| SpawnEntry {
|
||||
entity: entity.to_owned(),
|
||||
weight,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
};
|
||||
vec![
|
||||
e("minecraft:blaze", 10, 2, 3, false),
|
||||
e("minecraft:zombified_piglin", 5, 4, 4, true),
|
||||
e("minecraft:wither_skeleton", 8, 5, 5, true),
|
||||
e("minecraft:skeleton", 2, 5, 5, true),
|
||||
e("minecraft:magma_cube", 3, 4, 4, true),
|
||||
]
|
||||
pub fn fortress_enemies() -> &'static [SpawnEntry] {
|
||||
static ENEMIES: std::sync::LazyLock<Vec<SpawnEntry>> = std::sync::LazyLock::new(|| {
|
||||
let e = |entity: &str, weight, min, max, constant| SpawnEntry {
|
||||
entity: entity.to_owned(),
|
||||
weight,
|
||||
min,
|
||||
max,
|
||||
constant,
|
||||
};
|
||||
vec![
|
||||
e("minecraft:blaze", 10, 2, 3, false),
|
||||
e("minecraft:zombified_piglin", 5, 4, 4, true),
|
||||
e("minecraft:wither_skeleton", 8, 5, 5, true),
|
||||
e("minecraft:skeleton", 2, 5, 5, true),
|
||||
e("minecraft:magma_cube", 3, 4, 4, true),
|
||||
]
|
||||
});
|
||||
&ENEMIES
|
||||
}
|
||||
|
||||
/// `NaturalSpawner.mobsAt`: the fortress bounds on nether bricks, then
|
||||
/// `ChunkGenerator.getMobsAt` (the first referenced structure whose override
|
||||
/// for the category holds the position), else the biome's list.
|
||||
/// for the category holds the position), else the biome's list. Borrowed from
|
||||
/// the level: a spawn attempt looks it up up to twice.
|
||||
pub fn mobs_at<L: NaturalLevel>(
|
||||
l: &L,
|
||||
category: Category,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
) -> Vec<SpawnEntry> {
|
||||
) -> &[SpawnEntry] {
|
||||
// Only a fortress or a structure overriding this category's spawns can
|
||||
// change the list, so only theirs are looked up (the references reaching
|
||||
// most chunks are mineshafts, which override nothing).
|
||||
@@ -738,13 +742,12 @@ pub fn mobs_at<L: NaturalLevel>(
|
||||
if let Some(o) = overrides.get(&s.id, category.name())
|
||||
&& (if o.piece { s.in_piece } else { s.in_bounds })
|
||||
{
|
||||
return o.spawns.clone();
|
||||
return &o.spawns;
|
||||
}
|
||||
}
|
||||
l.biome_spawns(x, y, z)
|
||||
.and_then(|b| b.get(category.name()))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// `WeightedList.getRandom`.
|
||||
@@ -1103,7 +1106,7 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
{
|
||||
None
|
||||
} else {
|
||||
weighted_pick(&mobs_at(l, category, x, y, z), random)
|
||||
weighted_pick(mobs_at(l, category, x, y, z), random)
|
||||
};
|
||||
let Some(entry) = list else {
|
||||
break;
|
||||
|
||||
@@ -247,6 +247,14 @@ pub struct PlayerMotion {
|
||||
pub glide_ending: bool,
|
||||
/// Glider durability to take this tick (updateFallFlying).
|
||||
pub glider_wear: u32,
|
||||
/// `LivingEntity.autoSpinAttackTicks` and `autoSpinAttackDmg`: Riptide's
|
||||
/// spin. The damage stays until the game has dealt the hit.
|
||||
pub auto_spin_attack_ticks: i32,
|
||||
pub auto_spin_attack_dmg: f32,
|
||||
/// The living entities the game put near a spinning player (id, box).
|
||||
pub spin_targets: Vec<(u32, Aabb)>,
|
||||
/// The entity the spin touched this tick (`doAutoAttackOnTouch`).
|
||||
pub spin_hit: Option<u32>,
|
||||
pub sprinting: bool,
|
||||
pub sprint_input: crate::sprint::SprintState,
|
||||
pub shift_key_down: bool,
|
||||
@@ -262,6 +270,10 @@ pub struct PlayerMotion {
|
||||
pub steps: MovementDistances,
|
||||
pub needs_sync: bool,
|
||||
pub sync_position: bool,
|
||||
/// Moved by an ender pearl (`ServerPlayer.teleport`): a server sends the
|
||||
/// position packet even when the player lands where they stood. The
|
||||
/// server takes it at the end of a guest's turn.
|
||||
pub teleported: bool,
|
||||
pub events: Vec<MotionEvent>,
|
||||
pub attributes: LiveAttributes<Box<dyn FnMut() -> i32>>,
|
||||
pub impulse: ImpulseContext,
|
||||
@@ -409,6 +421,10 @@ impl PlayerMotion {
|
||||
fall_fly_ticks: 0,
|
||||
glide_ending: false,
|
||||
glider_wear: 0,
|
||||
auto_spin_attack_ticks: 0,
|
||||
auto_spin_attack_dmg: 0.0,
|
||||
spin_targets: Vec::new(),
|
||||
spin_hit: None,
|
||||
sprinting: false,
|
||||
sprint_input: crate::sprint::SprintState::default(),
|
||||
shift_key_down: false,
|
||||
@@ -428,6 +444,7 @@ impl PlayerMotion {
|
||||
},
|
||||
needs_sync: false,
|
||||
sync_position: false,
|
||||
teleported: false,
|
||||
events: Vec::new(),
|
||||
attributes,
|
||||
fluids: PlayerFluids::default(),
|
||||
@@ -919,6 +936,7 @@ impl MotionWorld<'_, '_, '_> {
|
||||
input.z = 0.0;
|
||||
}
|
||||
self.jump_phase()?;
|
||||
let before_travel = self.player.bounds();
|
||||
// Player.travel: swim steering, then LivingEntity.travel (fluids
|
||||
// first, then gliding, then air), keeping 0.6 of the vertical speed
|
||||
// while flying.
|
||||
@@ -973,8 +991,26 @@ impl MotionWorld<'_, '_, '_> {
|
||||
{
|
||||
self.freeze_damage()?;
|
||||
}
|
||||
if self.player.auto_spin_attack_ticks > 0 {
|
||||
self.player.auto_spin_attack_ticks -= 1;
|
||||
self.check_auto_spin_attack(before_travel);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `LivingEntity.checkAutoSpinAttack`: the first living entity in the
|
||||
/// box swept this tick takes the hit and ends the spin, bouncing the
|
||||
/// player back at a fifth of its speed; a wall ends it too.
|
||||
fn check_auto_spin_attack(&mut self, before: Aabb) {
|
||||
let swept = before.union_bounds(self.player.bounds());
|
||||
let p = &mut self.player;
|
||||
if let Some(&(id, _)) = p.spin_targets.iter().find(|(_, b)| b.intersects(swept)) {
|
||||
p.spin_hit = Some(id);
|
||||
p.auto_spin_attack_ticks = 0;
|
||||
p.velocity = p.velocity.scale(-0.2);
|
||||
} else if p.collision.horizontal {
|
||||
p.auto_spin_attack_ticks = 0;
|
||||
}
|
||||
}
|
||||
/// `hurtServer(damageSources().freeze(), 1)` for a fully frozen player.
|
||||
fn freeze_damage(&mut self) -> Result<()> {
|
||||
let id = self
|
||||
@@ -2659,6 +2695,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let ahead = Aabb::new(Vec3::new(20.2, 9.0, 21.5), Vec3::new(20.8, 11.0, 22.1));
|
||||
for target in [None, Some(ahead)] {
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
|
||||
player.auto_spin_attack_ticks = 20;
|
||||
player.auto_spin_attack_dmg = 8.0;
|
||||
player.velocity = Vec3::new(0.0, 0.0, 1.0);
|
||||
player.spin_targets = target.map(|b| (7, b)).into_iter().collect();
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
w.simulation_tick([0.0, 0.0], false).unwrap();
|
||||
if target.is_some() {
|
||||
// Touched: the hit, no more spin, bounced back at a fifth.
|
||||
assert_eq!(w.player.spin_hit, Some(7));
|
||||
assert_eq!(w.player.auto_spin_attack_ticks, 0);
|
||||
assert!(w.player.velocity.z < 0.0, "{:?}", w.player.velocity);
|
||||
assert_eq!(w.player.pose, crate::pose::Pose::Standing);
|
||||
} else {
|
||||
assert_eq!(w.player.spin_hit, None);
|
||||
assert_eq!(w.player.auto_spin_attack_ticks, 19);
|
||||
assert_eq!(w.player.pose, crate::pose::Pose::SpinAttack);
|
||||
assert_eq!(w.player.dimensions.height, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_landing_glide_lasts_a_tick_so_a_jump_keeps_its_speed() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
|
||||
@@ -14,6 +14,7 @@ pub enum Pose {
|
||||
Crouching,
|
||||
Swimming,
|
||||
FallFlying,
|
||||
SpinAttack,
|
||||
}
|
||||
impl Pose {
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -22,13 +23,14 @@ impl Pose {
|
||||
Self::Crouching => "CROUCHING",
|
||||
Self::Swimming => "SWIMMING",
|
||||
Self::FallFlying => "FALL_FLYING",
|
||||
Self::SpinAttack => "SPIN_ATTACK",
|
||||
}
|
||||
}
|
||||
pub fn dimensions(self, scale: f32) -> Dimensions {
|
||||
let (height, eye_height) = match self {
|
||||
Self::Standing => (1.8, 1.62),
|
||||
Self::Crouching => (1.5, 1.27),
|
||||
Self::Swimming | Self::FallFlying => (0.6, 0.4),
|
||||
Self::Swimming | Self::FallFlying | Self::SpinAttack => (0.6, 0.4),
|
||||
};
|
||||
Dimensions {
|
||||
width: 0.6 * scale,
|
||||
@@ -98,11 +100,13 @@ impl MotionWorld<'_, '_, '_> {
|
||||
if !self.can_fit_pose(Pose::Swimming)? {
|
||||
return Ok(());
|
||||
}
|
||||
// Player.getDesiredPose: swimming before gliding.
|
||||
// Player.getDesiredPose: swimming, gliding, Riptide's spin.
|
||||
let desired = if self.player.swimming {
|
||||
Pose::Swimming
|
||||
} else if self.player.fall_flying {
|
||||
Pose::FallFlying
|
||||
} else if self.player.auto_spin_attack_ticks > 0 {
|
||||
Pose::SpinAttack
|
||||
} else if self.player.shift_key_down && !self.player.flying {
|
||||
Pose::Crouching
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Named timings within a frame, for finding what makes a frame slow:
|
||||
//! `HORIZON_SPIKES=MS` prints the spans of every frame whose CPU time
|
||||
//! exceeds MS milliseconds, and of every server tick (dedicated or
|
||||
//! integrated) longer than MS. Costs one clock read per span when enabled
|
||||
//! and a relaxed load otherwise.
|
||||
//! integrated) longer than MS. Each span keeps its wall time and the
|
||||
//! thread's CPU time inside it, so a span that waited (for a lock, the GPU
|
||||
//! or a core on a busy machine) shows apart from one that worked. Costs two
|
||||
//! clock reads per span when enabled and a relaxed load otherwise.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
@@ -11,7 +13,15 @@ use std::{
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
thread_local! {
|
||||
static SPANS: RefCell<Vec<(&'static str, f32)>> = const { RefCell::new(Vec::new()) };
|
||||
static SPANS: RefCell<Vec<Span>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// A named timing: wall milliseconds and the thread's CPU milliseconds in it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Span {
|
||||
pub name: &'static str,
|
||||
pub ms: f32,
|
||||
pub cpu_ms: f32,
|
||||
}
|
||||
|
||||
pub fn enable(on: bool) {
|
||||
@@ -23,10 +33,13 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
return f();
|
||||
}
|
||||
let started = Instant::now();
|
||||
let (started, cpu) = (Instant::now(), thread_cpu());
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
SPANS.with(|s| s.borrow_mut().push((name, ms)));
|
||||
let cpu_ms = thread_cpu()
|
||||
.zip(cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
out
|
||||
}
|
||||
/// The CPU time this thread has used, where the platform tells it: a tick
|
||||
@@ -42,21 +55,84 @@ pub fn thread_cpu() -> Option<Duration> {
|
||||
None
|
||||
}
|
||||
/// The frame's spans, summed by name, slowest first; clears them.
|
||||
pub fn take() -> Vec<(&'static str, f32)> {
|
||||
pub fn take() -> Vec<Span> {
|
||||
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
|
||||
let mut sums: Vec<(&'static str, f32)> = Vec::new();
|
||||
for (name, ms) in spans {
|
||||
match sums.iter_mut().find(|(n, _)| *n == name) {
|
||||
Some(entry) => entry.1 += ms,
|
||||
None => sums.push((name, ms)),
|
||||
let mut sums: Vec<Span> = Vec::new();
|
||||
for span in spans {
|
||||
match sums.iter_mut().find(|s| s.name == span.name) {
|
||||
Some(sum) => {
|
||||
sum.ms += span.ms;
|
||||
sum.cpu_ms += span.cpu_ms;
|
||||
}
|
||||
None => sums.push(span),
|
||||
}
|
||||
}
|
||||
sums.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
sums.sort_by(|a, b| b.ms.total_cmp(&a.ms));
|
||||
sums
|
||||
}
|
||||
/// `name wall` for spans of at least 0.5 ms, with `/cpu` when the thread
|
||||
/// worked for less than nine tenths of it: the slow-frame and slow-tick lines.
|
||||
pub fn describe(spans: &[Span]) -> String {
|
||||
let list: Vec<String> = spans
|
||||
.iter()
|
||||
.filter(|s| s.ms >= 0.5)
|
||||
.map(|s| {
|
||||
if s.cpu_ms < s.ms * 0.9 {
|
||||
format!("{} {:.1}/{:.1}", s.name, s.ms, s.cpu_ms)
|
||||
} else {
|
||||
format!("{} {:.1}", s.name, s.ms)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
list.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Span;
|
||||
|
||||
/// Spans of one name add up; a span the thread spent mostly waiting
|
||||
/// shows its CPU time after the wall time, short ones are left out.
|
||||
#[test]
|
||||
fn spans_sum_by_name_and_show_waits() {
|
||||
super::SPANS.with(|s| {
|
||||
s.borrow_mut().extend([
|
||||
Span {
|
||||
name: "tick",
|
||||
ms: 2.0,
|
||||
cpu_ms: 2.0,
|
||||
},
|
||||
Span {
|
||||
name: "submit",
|
||||
ms: 30.0,
|
||||
cpu_ms: 0.2,
|
||||
},
|
||||
Span {
|
||||
name: "tick",
|
||||
ms: 3.0,
|
||||
cpu_ms: 2.9,
|
||||
},
|
||||
Span {
|
||||
name: "hud",
|
||||
ms: 0.3,
|
||||
cpu_ms: 0.3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
let spans = super::take();
|
||||
assert_eq!(
|
||||
spans[0],
|
||||
Span {
|
||||
name: "submit",
|
||||
ms: 30.0,
|
||||
cpu_ms: 0.2
|
||||
}
|
||||
);
|
||||
assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0));
|
||||
assert_eq!(super::describe(&spans), "submit 30.0/0.2, tick 5.0");
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
/// The thread's CPU clock moves with work done on it, not with time
|
||||
/// spent asleep.
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -198,8 +198,10 @@ pub fn anvil_result(
|
||||
let mut repair_item_count_cost = 0;
|
||||
let mut naming_cost = 0;
|
||||
if let Some(addition) = addition {
|
||||
// addition.has(STORED_ENCHANTMENTS): only enchanted books carry that component.
|
||||
let using_book = &*addition.item == "minecraft:enchanted_book";
|
||||
// addition.has(STORED_ENCHANTMENTS): an enchanted book (its default
|
||||
// component), or any item given stored enchantments.
|
||||
let using_book = &*addition.item == "minecraft:enchanted_book"
|
||||
|| !addition.stored_enchantments().is_empty();
|
||||
if is_damageable(items, &result) && is_valid_repair_item(e, items, input, addition) {
|
||||
let max = max_damage(items, &result);
|
||||
let mut repair = (result.damage as i32).min(max / 4);
|
||||
@@ -401,13 +403,13 @@ pub fn grindstone_result(
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
/// Experience from the grindstone result slot's `getExperienceAmount`.
|
||||
pub fn grindstone_experience(
|
||||
/// The grindstone result slot's `getExperienceAmount` before its roll: the
|
||||
/// minimum costs of the inputs' non-curse enchantments.
|
||||
pub fn grindstone_xp_base(
|
||||
e: &Enchantments,
|
||||
random: &mut LegacyRandom,
|
||||
input: Option<&ItemStack>,
|
||||
additional: Option<&ItemStack>,
|
||||
) -> u32 {
|
||||
) -> i32 {
|
||||
let from = |s: Option<&ItemStack>| -> i32 {
|
||||
s.map_or(0, |s| {
|
||||
s.crafting_enchantments()
|
||||
@@ -417,7 +419,10 @@ pub fn grindstone_experience(
|
||||
.sum()
|
||||
})
|
||||
};
|
||||
let amount = from(input) + from(additional);
|
||||
from(input) + from(additional)
|
||||
}
|
||||
/// `getExperienceAmount`'s roll of the sum: `half + nextInt(half)`.
|
||||
pub fn grindstone_xp_roll(amount: i32, random: &mut LegacyRandom) -> u32 {
|
||||
if amount > 0 {
|
||||
let half = (f64::from(amount) / 2.0).ceil() as i32;
|
||||
(half + random.next_int_bound(half).unwrap_or(0)) as u32
|
||||
@@ -425,6 +430,15 @@ pub fn grindstone_experience(
|
||||
0
|
||||
}
|
||||
}
|
||||
/// Experience from the grindstone result slot's `getExperienceAmount`.
|
||||
pub fn grindstone_experience(
|
||||
e: &Enchantments,
|
||||
random: &mut LegacyRandom,
|
||||
input: Option<&ItemStack>,
|
||||
additional: Option<&ItemStack>,
|
||||
) -> u32 {
|
||||
grindstone_xp_roll(grindstone_xp_base(e, input, additional), random)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- smithing & stonecutting
|
||||
|
||||
@@ -450,6 +464,7 @@ pub struct Stonecutting {
|
||||
pub input: Ingredient,
|
||||
pub result: ItemStack,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct StationRecipes {
|
||||
pub smithing: Vec<Smithing>,
|
||||
/// Sorted by recipe id, the registry order vanilla lists them in.
|
||||
|
||||
@@ -436,7 +436,7 @@ impl Streaming {
|
||||
thread::Builder::new()
|
||||
.name(format!("horizon-worker-{i}"))
|
||||
.spawn(move || {
|
||||
// A dedicated server's tick comes first (horizon_threads).
|
||||
// Ticks and frames come first (horizon_threads).
|
||||
horizon_threads::behind_server();
|
||||
let mut worker = generator.worker();
|
||||
// The face visibility table and its occlusion cache stay with
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The game's part in opt-in data collection (`horizon_telemetry`, docs/telemetry.md). When the
|
||||
//! player allows it, the game keeps a scrubbed crash report, or a play session's performance
|
||||
//! summary, in the outbox. It never sends anything itself: the launcher does, at its next start.
|
||||
//! summary, in the outbox. It never sends those itself: the launcher does, at its next start (the
|
||||
//! game's only sending is the anonymous "I'm playing" ping, `heartbeat.rs`).
|
||||
//! Without the player's yes for that kind nothing is kept, and crash-reports/ stays local.
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
@@ -707,7 +707,8 @@ pub fn carry(
|
||||
let mut biome_conventions = BTreeSet::new();
|
||||
let set_tags = crate::worldgen::structure_set_tags(&mut zip);
|
||||
let piece_counts = crate::worldgen::piece_counts(&mut zip);
|
||||
let processor_data = crate::processors::ProcessorData::read(&mut zip);
|
||||
let mut processor_data = crate::processors::ProcessorData::read(&mut zip);
|
||||
processor_data.mark_loaded(loaded);
|
||||
report.namespaces = extra_namespaces(&mut zip, id, pack.mods);
|
||||
let owned: Vec<&str> = std::iter::once(id.as_str())
|
||||
.chain(report.namespaces.iter().map(String::as_str))
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
//! `condition` become the game's `horizon:apply_random` and
|
||||
//! `horizon:condition`, with the lists inside them carried the same way,
|
||||
//! and its `set_block` (simple or weighted states) `horizon:set_block`.
|
||||
//! - Repurposed Structures' mineshaft sky view becomes the game's
|
||||
//! `horizon:mineshaft_sky_view`.
|
||||
//! - Choice Theorem's Overhauled Village's `modular_compat` runs another
|
||||
//! list only when the mod it names is loaded: that list is written in its
|
||||
//! place when the mod is ported along, and it is taken out (a no-op, as in
|
||||
//! the mod) when it is not.
|
||||
//!
|
||||
//! What is not here is taken out of its list later and counted
|
||||
//! (`worldgen::upgrade`). The entity NBT is written in 26.3's shape (an
|
||||
@@ -37,9 +43,37 @@ pub struct ProcessorData {
|
||||
pub replace_vanilla: BTreeSet<(String, String)>,
|
||||
/// The mod's processor lists by id, for Lithostitched's references.
|
||||
pub lists: BTreeMap<String, Value>,
|
||||
/// The mods named by its `ctov:modular_compat` processors that are
|
||||
/// ported along ([`Self::mark_loaded`]).
|
||||
pub compat_loaded: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Choice Theorem's Overhauled Village's processor that runs the list
|
||||
/// `reference` when mod `modid` is loaded (ModularCompatProcessor).
|
||||
const MODULAR_COMPAT: &str = "ctov:modular_compat";
|
||||
|
||||
impl ProcessorData {
|
||||
/// Notes which mods its compat processors name are `loaded`.
|
||||
pub fn mark_loaded(&mut self, loaded: &dyn Fn(&str) -> bool) {
|
||||
fn walk(v: &Value, out: &mut BTreeSet<String>) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.get("processor_type").and_then(Value::as_str) == Some(MODULAR_COMPAT) {
|
||||
if let Some(id) = o.get("modid").and_then(Value::as_str) {
|
||||
out.insert(id.to_owned());
|
||||
}
|
||||
}
|
||||
o.values().for_each(|v| walk(v, out));
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|v| walk(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut named = BTreeSet::new();
|
||||
self.lists.values().for_each(|l| walk(l, &mut named));
|
||||
self.compat_loaded = named.into_iter().filter(|m| loaded(m)).collect();
|
||||
}
|
||||
|
||||
pub fn read<R: Read + Seek>(zip: &mut zip::ZipArchive<R>) -> Self {
|
||||
let mut out = Self::default();
|
||||
for i in 0..zip.len() {
|
||||
@@ -408,6 +442,17 @@ fn references(
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
let mut changed = false;
|
||||
for p in list {
|
||||
if p["processor_type"] == MODULAR_COMPAT && depth < 8 {
|
||||
*carried.entry(MODULAR_COMPAT.into()).or_default() += 1;
|
||||
changed = true;
|
||||
let loaded = p["modid"]
|
||||
.as_str()
|
||||
.is_some_and(|m| data.compat_loaded.contains(m));
|
||||
if let Some(inner) = loaded.then(|| referenced(&p["reference"], data)).flatten() {
|
||||
out.extend(references(inner, data, carried, depth + 1).0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let named = (p["processor_type"] == "lithostitched:reference" && depth < 8)
|
||||
.then(|| referenced(&p["processor_lists"], data))
|
||||
.flatten();
|
||||
@@ -491,6 +536,10 @@ fn as_data(kind: &str, p: &Value, data: &ProcessorData) -> Option<Vec<Value>> {
|
||||
"repurposed_structures:waterlogging_when_replacing_water_processor" => {
|
||||
vec![json!({"processor_type": "horizon:waterlog_in_world_water"})]
|
||||
}
|
||||
// Its mineshafts: nothing above the ocean floor but grounded rails.
|
||||
"repurposed_structures:mineshaft_sky_view_processor" => {
|
||||
vec![json!({"processor_type": "horizon:mineshaft_sky_view"})]
|
||||
}
|
||||
"moogs_structures:vanilla_loot_swap_processor" => {
|
||||
let key = (
|
||||
p["modid"].as_str()?.to_owned(),
|
||||
@@ -1344,7 +1393,8 @@ mod tests {
|
||||
#[test]
|
||||
fn repurposed_waterlogging_follows_the_world() {
|
||||
let mut list = json!({"processors": [{"processor_type":
|
||||
"repurposed_structures:waterlogging_when_replacing_water_processor"}]});
|
||||
"repurposed_structures:waterlogging_when_replacing_water_processor"},
|
||||
{"processor_type": "repurposed_structures:mineshaft_sky_view_processor"}]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry(
|
||||
"processor_list",
|
||||
@@ -1354,7 +1404,8 @@ mod tests {
|
||||
));
|
||||
assert_eq!(
|
||||
list["processors"],
|
||||
json!([{"processor_type": "horizon:waterlog_in_world_water"}])
|
||||
json!([{"processor_type": "horizon:waterlog_in_world_water"},
|
||||
{"processor_type": "horizon:mineshaft_sky_view"}])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1391,6 +1442,27 @@ mod tests {
|
||||
assert_eq!(p[1]["processor_type"], "lithostitched:block_swap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modular_compat_runs_its_list_only_with_its_mod() {
|
||||
let mut data = ProcessorData::default();
|
||||
let swap = json!({"processor_type": "minecraft:rule", "rules": []});
|
||||
data.lists
|
||||
.insert("v:house_ns".into(), json!({"processors": [swap.clone()]}));
|
||||
let compat = |m: &str| json!({"processor_type": "ctov:modular_compat", "modid": m, "reference": "v:house_ns"});
|
||||
let gravity = json!({"processor_type": "minecraft:gravity"});
|
||||
data.lists.insert(
|
||||
"v:house".into(),
|
||||
json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]}),
|
||||
);
|
||||
data.mark_loaded(&|m| m == "ns");
|
||||
assert_eq!(data.compat_loaded, BTreeSet::from(["ns".to_owned()]));
|
||||
let mut v = json!({"processors": [compat("ns"), compat("absent"), gravity.clone()]});
|
||||
let mut carried = BTreeMap::new();
|
||||
assert!(carry("processor_list", &mut v, &data, &mut carried));
|
||||
assert_eq!(v, json!({"processors": [swap, gravity]}));
|
||||
assert_eq!(carried["ctov:modular_compat"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lithostitched_references_are_written_in() {
|
||||
let mut data = data();
|
||||
|
||||
@@ -86,6 +86,31 @@ const YUNG_REPLACES: [(&str, &str); 7] = [
|
||||
("betterfortresses:fortress", "minecraft:fortress"),
|
||||
("bettermineshafts:mineshaft_", "minecraft:mineshaft"),
|
||||
];
|
||||
/// Mo' Structures' placement (`mostructures:type`) takes its spacing and
|
||||
/// separation from the mod's config, not its files: the defaults it writes
|
||||
/// (MoStructuresConfig), by config key, as (spacing, separation).
|
||||
const MO_SPREADS: [(&str, i64, i64); 17] = [
|
||||
("abandoned_church", 33, 20),
|
||||
("air_balloon", 22, 12),
|
||||
("barn_house", 32, 16),
|
||||
("big_pyramid", 32, 25),
|
||||
("ice_tower", 28, 20),
|
||||
("jungle_pyramid", 25, 22),
|
||||
("killer_bunny_castle", 32, 22),
|
||||
("lighthouse", 24, 14),
|
||||
("moai", 17, 15),
|
||||
("pillager_factory", 34, 16),
|
||||
("pillager_mines", 40, 27),
|
||||
("pirate_ship", 32, 16),
|
||||
("tavern", 26, 16),
|
||||
("the_castle_in_the_sky", 32, 22),
|
||||
("villager_market", 32, 16),
|
||||
("villager_tower", 26, 16),
|
||||
("sphinx", 24, 16),
|
||||
];
|
||||
/// Mo' Structures keeps a start out of this many chunks around a start of
|
||||
/// each set in its `structure_set_to_avoid`.
|
||||
const MO_AVOID_CHUNKS: i64 = 3;
|
||||
/// Moog's Structure Lib spreads its structures 1.65 times as far apart as
|
||||
/// its files say (AdvancedRandomSpread's constructor).
|
||||
const MOOGS_SPACING: f64 = 1.65;
|
||||
@@ -510,12 +535,44 @@ fn upgrade_types(
|
||||
v["type"] = "minecraft:jigsaw".into();
|
||||
true
|
||||
}
|
||||
// Mo' Structures' ModStructure: vanilla's jigsaw that starts only
|
||||
// where the terrain under the 5×5 chunks around varies by no
|
||||
// more than `heightRange` blocks (-1: anywhere).
|
||||
Some("mostructures:generic") => {
|
||||
let range = v
|
||||
.as_object_mut()
|
||||
.and_then(|o| o.remove("heightRange"))
|
||||
.and_then(|r| r.as_i64())
|
||||
.unwrap_or(-1);
|
||||
if range >= 0 {
|
||||
v["horizon:start_checks"] =
|
||||
serde_json::json!({"terrain_radius": 2, "terrain_range": range});
|
||||
}
|
||||
v["type"] = "minecraft:jigsaw".into();
|
||||
true
|
||||
}
|
||||
// Formations' SimpleStructure: its pool and placement, laid out
|
||||
// by the game (`horizon_world::worldgen::structure::formations`).
|
||||
Some("formations:simple") => {
|
||||
v["type"] = "horizon:formations".into();
|
||||
true
|
||||
}
|
||||
// Its PiecedStructure: the same start, then vanilla's jigsaw
|
||||
// (`horizon:pieced`).
|
||||
Some("formations:pieced") => {
|
||||
let Some(o) = v.as_object_mut() else {
|
||||
return false;
|
||||
};
|
||||
let pool = o.remove("start_pool").unwrap_or_default();
|
||||
let mut pieced = serde_json::Map::new();
|
||||
for k in ["max_depth", "max_distance_from_center"] {
|
||||
pieced.insert(k.into(), o.remove(k).unwrap_or_default());
|
||||
}
|
||||
o.insert("template".into(), pool);
|
||||
o.insert("horizon:pieced".into(), pieced.into());
|
||||
o.insert("type".into(), "horizon:formations".into());
|
||||
true
|
||||
}
|
||||
Some(t) if !t.starts_with("minecraft:") => {
|
||||
unsupported(t);
|
||||
false
|
||||
@@ -533,6 +590,7 @@ fn upgrade_types(
|
||||
p["type"] = "minecraft:random_spread".into();
|
||||
true
|
||||
}
|
||||
Some("mostructures:type") => mo_placement(&mut v["placement"], lost),
|
||||
Some(t) if YUNG_SPREADS.contains(&t) => {
|
||||
let t = t.to_owned();
|
||||
yung_placement(&t, &mut v["placement"], set_tags, lost);
|
||||
@@ -1022,6 +1080,41 @@ fn placement_rules(
|
||||
}
|
||||
}
|
||||
|
||||
/// Mo' Structures' placement as vanilla's random spread: the spacing and
|
||||
/// separation of its config key, and its sets to avoid as
|
||||
/// `horizon:exclusion_zone` tested by their spread alone
|
||||
/// (ModStructurePlacement.exclusionZoneCheck). A key the config does not
|
||||
/// have fails the mod's codec, and the set stays as it is.
|
||||
fn mo_placement(p: &mut Value, lost: &mut BTreeMap<String, usize>) -> bool {
|
||||
let key = p["config_key"].as_str().unwrap_or_default();
|
||||
let Some(&(_, spacing, separation)) = MO_SPREADS.iter().find(|(k, ..)| *k == key) else {
|
||||
*lost.entry("config_key".into()).or_default() += 1;
|
||||
return false;
|
||||
};
|
||||
let Some(o) = p.as_object_mut() else {
|
||||
return false;
|
||||
};
|
||||
o.remove("config_key");
|
||||
o.insert("spacing".into(), spacing.into());
|
||||
o.insert("separation".into(), separation.into());
|
||||
let avoid = o.remove("structure_set_to_avoid");
|
||||
let sets: Vec<Value> = avoid
|
||||
.iter()
|
||||
.flat_map(|a| a.as_array().into_iter().flatten())
|
||||
.filter(|s| s.is_string())
|
||||
.cloned()
|
||||
.collect();
|
||||
if !sets.is_empty() {
|
||||
o.insert(
|
||||
"horizon:exclusion_zone".into(),
|
||||
serde_json::json!({"other_sets": sets, "chunk_count": MO_AVOID_CHUNKS,
|
||||
"their_zones": false}),
|
||||
);
|
||||
}
|
||||
o.insert("type".into(), "minecraft:random_spread".into());
|
||||
true
|
||||
}
|
||||
|
||||
/// One of YUNG's placements as vanilla's random spread with `horizon:` keys:
|
||||
/// the enhanced exclusion zone as `horizon:exclusion_zone`; the temples'
|
||||
/// ocean and river search (findBiomeHorizontal around the chunk's middle
|
||||
@@ -1222,6 +1315,21 @@ mod tests {
|
||||
|
||||
const NO_TAGS: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
|
||||
#[test]
|
||||
fn formations_pieced_structure_keeps_its_jigsaw() {
|
||||
let mut v = json!({"type": "formations:pieced", "start_pool": "fo:fountain/fountains",
|
||||
"max_depth": 2, "max_distance_from_center": 116, "placement": "surface"});
|
||||
let mut report = Upgrade::default();
|
||||
assert!(upgrade("structure", &mut v, &mut report, &BTreeMap::new()));
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"type": "horizon:formations", "template": "fo:fountain/fountains",
|
||||
"placement": "surface",
|
||||
"horizon:pieced": {"max_depth": 2, "max_distance_from_center": 116}})
|
||||
);
|
||||
assert!(report.unsupported.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formations_become_the_games() {
|
||||
let mut report = Upgrade::default();
|
||||
@@ -1247,12 +1355,7 @@ mod tests {
|
||||
(json!(6), json!(1))
|
||||
);
|
||||
assert!(e(0).get("ground_level").is_none());
|
||||
// The pieced structure stays out.
|
||||
let mut pieced = json!({"type": "formations:pieced", "start_pool": "f:p", "max_depth": 2,
|
||||
"max_distance_from_center": 40, "placement": "surface", "biomes": "#f:x"});
|
||||
assert!(!upgrade("structure", &mut pieced, &mut report, &NO_TAGS));
|
||||
assert_eq!(report.unsupported.get("formations:pieced"), Some(&1));
|
||||
assert_eq!(report.unsupported.len(), 1);
|
||||
assert!(report.unsupported.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1385,6 +1488,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mo_structures_become_vanillas_jigsaw_and_spread() {
|
||||
let mut report = Upgrade::default();
|
||||
let mut s = json!({"type": "mostructures:generic", "start_pool": "mostructures:a/base",
|
||||
"size": 7, "heightRange": 25, "use_expansion_hack": true,
|
||||
"start_height": {"absolute": 0}, "max_distance_from_center": 80});
|
||||
assert!(upgrade("structure", &mut s, &mut report, &BTreeMap::new()));
|
||||
assert_eq!(s["type"], "minecraft:jigsaw");
|
||||
assert!(s.get("heightRange").is_none());
|
||||
assert_eq!(
|
||||
s["horizon:start_checks"],
|
||||
json!({"terrain_radius": 2, "terrain_range": 25})
|
||||
);
|
||||
let mut anywhere = json!({"type": "mostructures:generic", "heightRange": -1});
|
||||
assert!(upgrade(
|
||||
"structure",
|
||||
&mut anywhere,
|
||||
&mut report,
|
||||
&BTreeMap::new()
|
||||
));
|
||||
assert!(anywhere.get("horizon:start_checks").is_none());
|
||||
|
||||
// The config's spacing and separation, not the file's.
|
||||
let mut set = json!({"placement": {"type": "mostructures:type", "config_key": "moai",
|
||||
"salt": 12994829, "spacing": 17, "separation": 15,
|
||||
"structure_set_to_avoid": ["minecraft:villages", "mostructures:lighthouse"]}});
|
||||
assert!(upgrade(
|
||||
"structure_set",
|
||||
&mut set,
|
||||
&mut report,
|
||||
&BTreeMap::new()
|
||||
));
|
||||
assert_eq!(
|
||||
set["placement"],
|
||||
json!({"type": "minecraft:random_spread", "salt": 12994829, "spacing": 17,
|
||||
"separation": 15, "horizon:exclusion_zone": {"chunk_count": 3,
|
||||
"other_sets": ["minecraft:villages", "mostructures:lighthouse"],
|
||||
"their_zones": false}})
|
||||
);
|
||||
let mut set = json!({"placement": {"type": "mostructures:type",
|
||||
"config_key": "jungle_pyramid", "salt": 1, "spacing": 16, "separation": 14}});
|
||||
assert!(upgrade(
|
||||
"structure_set",
|
||||
&mut set,
|
||||
&mut report,
|
||||
&BTreeMap::new()
|
||||
));
|
||||
assert_eq!(
|
||||
(
|
||||
&set["placement"]["spacing"],
|
||||
&set["placement"]["separation"]
|
||||
),
|
||||
(&json!(25), &json!(22))
|
||||
);
|
||||
assert!(set["placement"].get("horizon:exclusion_zone").is_none());
|
||||
let mut unknown = json!({"placement": {"type": "mostructures:type", "config_key": "x"}});
|
||||
assert!(!upgrade(
|
||||
"structure_set",
|
||||
&mut unknown,
|
||||
&mut report,
|
||||
&BTreeMap::new()
|
||||
));
|
||||
assert_eq!(report.rules_lost["config_key"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn super_exclusion_zones_name_their_sets() {
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
|
||||
@@ -28,6 +28,21 @@ pub mod component {
|
||||
pub const STORED_ENCHANTMENTS: i32 = 45;
|
||||
pub const DYED_COLOR: i32 = 47;
|
||||
pub const MAP_ID: i32 = 48;
|
||||
pub const MAP_POST_PROCESSING: i32 = 50;
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
}
|
||||
|
||||
/// A written book's content (`WrittenBookContent.STREAM_CODEC`): the
|
||||
/// filtered variants of the title and pages are read and dropped.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WrittenBook {
|
||||
pub title: String,
|
||||
pub author: String,
|
||||
pub generation: i32,
|
||||
/// Each page's text component.
|
||||
pub pages: Vec<Tag>,
|
||||
pub resolved: bool,
|
||||
}
|
||||
|
||||
/// The value of one patched data component. Components whose stream codec
|
||||
@@ -52,13 +67,49 @@ pub enum Component {
|
||||
hide: bool,
|
||||
hidden: Vec<i32>,
|
||||
},
|
||||
/// `WritableBookContent`: the pages (raw text).
|
||||
WritableBook(Vec<String>),
|
||||
WrittenBook(WrittenBook),
|
||||
}
|
||||
|
||||
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
|
||||
/// (dropped).
|
||||
fn filterable<T>(r: &mut Reader, value: impl Fn(&mut Reader) -> Result<T>) -> Result<T> {
|
||||
let raw = value(r)?;
|
||||
if r.bool()? {
|
||||
value(r)?;
|
||||
}
|
||||
Ok(raw)
|
||||
}
|
||||
impl Component {
|
||||
fn read(kind: i32, r: &mut Reader) -> Result<Self> {
|
||||
use component::*;
|
||||
Ok(match kind {
|
||||
MAX_STACK_SIZE | MAX_DAMAGE | DAMAGE | RARITY | REPAIR_COST | MAP_ID => {
|
||||
Self::Int(r.varint()?)
|
||||
MAX_STACK_SIZE | MAX_DAMAGE | DAMAGE | RARITY | REPAIR_COST | MAP_ID
|
||||
| MAP_POST_PROCESSING => Self::Int(r.varint()?),
|
||||
WRITABLE_BOOK_CONTENT => {
|
||||
let n = r.count(100)?;
|
||||
Self::WritableBook(
|
||||
(0..n)
|
||||
.map(|_| filterable(r, |r| r.string(1024)))
|
||||
.collect::<Result<_>>()?,
|
||||
)
|
||||
}
|
||||
WRITTEN_BOOK_CONTENT => {
|
||||
let title = filterable(r, |r| r.string(32))?;
|
||||
let author = r.string(32767)?;
|
||||
let generation = r.varint()?;
|
||||
let n = r.count(r.remaining())?;
|
||||
let pages = (0..n)
|
||||
.map(|_| filterable(r, |r| r.nbt()))
|
||||
.collect::<Result<_>>()?;
|
||||
Self::WrittenBook(WrittenBook {
|
||||
title,
|
||||
author,
|
||||
generation,
|
||||
pages,
|
||||
resolved: r.bool()?,
|
||||
})
|
||||
}
|
||||
DYED_COLOR => Self::Int(r.i32()?),
|
||||
UNBREAKABLE | CREATIVE_SLOT_LOCK => Self::Unit,
|
||||
@@ -144,6 +195,25 @@ impl Component {
|
||||
w.varint(hidden.len() as i32);
|
||||
hidden.iter().for_each(|h| w.varint(*h));
|
||||
}
|
||||
Self::WritableBook(pages) => {
|
||||
w.varint(pages.len() as i32);
|
||||
for page in pages {
|
||||
w.string(page);
|
||||
w.bool(false);
|
||||
}
|
||||
}
|
||||
Self::WrittenBook(b) => {
|
||||
w.string(&b.title);
|
||||
w.bool(false);
|
||||
w.string(&b.author);
|
||||
w.varint(b.generation);
|
||||
w.varint(b.pages.len() as i32);
|
||||
for page in &b.pages {
|
||||
w.nbt(page);
|
||||
w.bool(false);
|
||||
}
|
||||
w.bool(b.resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,12 +419,43 @@ mod tests {
|
||||
Component::Lore(vec![literal("a"), literal("b")]),
|
||||
));
|
||||
sword.removed.push(component::REPAIR_COST);
|
||||
let mut book = Stack::new(1100, 1);
|
||||
book.added.push((
|
||||
component::WRITTEN_BOOK_CONTENT,
|
||||
Component::WrittenBook(WrittenBook {
|
||||
title: "Notes".into(),
|
||||
author: "Guest".into(),
|
||||
generation: 1,
|
||||
pages: vec![literal("one"), literal("two")],
|
||||
resolved: true,
|
||||
}),
|
||||
));
|
||||
book.added
|
||||
.push((component::MAP_POST_PROCESSING, Component::Int(1)));
|
||||
round::<Option<Stack>, Slot>(Some(book));
|
||||
let mut quill = Stack::new(1099, 1);
|
||||
quill.added.push((
|
||||
component::WRITABLE_BOOK_CONTENT,
|
||||
Component::WritableBook(vec!["a".into(), String::new()]),
|
||||
));
|
||||
round::<Option<Stack>, Slot>(Some(quill));
|
||||
assert_eq!(sword.damage(), 17);
|
||||
round::<Option<Stack>, Slot>(Some(sword.clone()));
|
||||
round::<Option<Hashed>, HashedSlot>(Some(Hashed::of(&sword)));
|
||||
round::<Option<Hashed>, HashedSlot>(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_book_pages_are_read_and_dropped() {
|
||||
// A writable book with one page "hi" and its filtered "**".
|
||||
let bytes = [1, 7, 1, 0, 56, 1, 2, b'h', b'i', 1, 2, b'*', b'*'];
|
||||
let stack = Slot::read(&mut Reader::new(&bytes)).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
stack.component(component::WRITABLE_BOOK_CONTENT),
|
||||
Some(&Component::WritableBook(vec!["hi".into()]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stack_is_a_zero_count() {
|
||||
let mut w = Writer::new();
|
||||
|
||||
@@ -1712,6 +1712,25 @@ packet!(
|
||||
}
|
||||
);
|
||||
packet!(SelectTrade { item: i32 => VarInt });
|
||||
packet!(
|
||||
/// ServerboundContainerButtonClickPacket: `AbstractContainerMenu.clickMenuButton`
|
||||
/// (the loom's patterns, the stonecutter's recipes, the enchanting table's offers).
|
||||
ContainerButtonClick { container: i32 => VarInt, button: i32 => VarInt }
|
||||
);
|
||||
packet!(
|
||||
/// ServerboundContainerSlotStateChangedPacket: the crafter's slot toggle
|
||||
/// (`CrafterBlockEntity.setSlotState`).
|
||||
ContainerSlotStateChanged { slot: i32 => VarInt, container: i32 => VarInt, enabled: bool => Bool }
|
||||
);
|
||||
packet!(
|
||||
/// ServerboundRenameItemPacket: `AnvilMenu.setItemName`.
|
||||
RenameItem { name: String => Str }
|
||||
);
|
||||
packet!(
|
||||
/// ServerboundSetBeaconPacket: `BeaconMenu.updateEffects`, each power an
|
||||
/// optional `mob_effect` id.
|
||||
SetBeacon { primary: Option<i32> => Opt<VarInt>, secondary: Option<i32> => Opt<VarInt> }
|
||||
);
|
||||
packet!(Interact { id: i32 => VarInt, hand: i32 => VarInt, location: [f64; 3] => LpVec3, secondary: bool => Bool });
|
||||
packet!(
|
||||
/// ServerboundMovePlayerPacket flags: 1 on ground, 2 horizontal collision.
|
||||
@@ -1777,6 +1796,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
ClientInformation(ClientInformation) = CLIENT_INFORMATION,
|
||||
ClientTickEnd(Empty) = CLIENT_TICK_END,
|
||||
ConfigurationAcknowledged(Empty) = CONFIGURATION_ACKNOWLEDGED,
|
||||
ContainerButtonClick(ContainerButtonClick) = CONTAINER_BUTTON_CLICK,
|
||||
ContainerClick(ContainerClick) = CONTAINER_CLICK,
|
||||
ContainerClose(ContainerClose) = CONTAINER_CLOSE,
|
||||
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
|
||||
@@ -1794,9 +1814,12 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
PlayerInput(PlayerInput) = PLAYER_INPUT,
|
||||
PlayerLoaded(Empty) = PLAYER_LOADED,
|
||||
Pong(Ping) = PONG,
|
||||
RenameItem(RenameItem) = RENAME_ITEM,
|
||||
ContainerSlotStateChanged(ContainerSlotStateChanged) = CONTAINER_SLOT_STATE_CHANGED,
|
||||
Punch(Empty) = PUNCH,
|
||||
ResourcePack(ResourcePackResponse) = RESOURCE_PACK,
|
||||
SelectTrade(SelectTrade) = SELECT_TRADE,
|
||||
SetBeacon(SetBeacon) = SET_BEACON,
|
||||
SetCarriedItem(SetCarriedItem) = SET_CARRIED_ITEM,
|
||||
SetCreativeModeSlot(SetCreativeModeSlot) = SET_CREATIVE_MODE_SLOT,
|
||||
UseItem(UseItem) = USE_ITEM,
|
||||
@@ -2374,6 +2397,34 @@ mod tests {
|
||||
Serverbound::encode,
|
||||
Serverbound::decode,
|
||||
);
|
||||
// CONTAINER_ID and the button, both VarInts.
|
||||
let button = Serverbound::ContainerButtonClick(ContainerButtonClick {
|
||||
container: 3,
|
||||
button: 200,
|
||||
});
|
||||
assert_eq!(&button.encode()[1..], &[3, 0xC8, 1]);
|
||||
round_trip(button, Serverbound::encode, Serverbound::decode);
|
||||
// The name, a UTF-8 string with its VarInt length.
|
||||
let rename = Serverbound::RenameItem(RenameItem {
|
||||
name: "Blade".into(),
|
||||
});
|
||||
assert_eq!(&rename.encode()[1..], &[5, b'B', b'l', b'a', b'd', b'e']);
|
||||
round_trip(rename, Serverbound::encode, Serverbound::decode);
|
||||
// The slot, the container id (VarInts), then the new state.
|
||||
let toggle = Serverbound::ContainerSlotStateChanged(ContainerSlotStateChanged {
|
||||
slot: 4,
|
||||
container: 2,
|
||||
enabled: false,
|
||||
});
|
||||
assert_eq!(&toggle.encode()[1..], &[4, 2, 0]);
|
||||
round_trip(toggle, Serverbound::encode, Serverbound::decode);
|
||||
// Two optional mob effect ids: a presence byte, then the VarInt.
|
||||
let beacon = Serverbound::SetBeacon(SetBeacon {
|
||||
primary: Some(2),
|
||||
secondary: None,
|
||||
});
|
||||
assert_eq!(&beacon.encode()[1..], &[1, 2, 0]);
|
||||
round_trip(beacon, Serverbound::encode, Serverbound::decode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# horizon-telemetry-server
|
||||
|
||||
This server receives what players choose to send (performance summaries, crash and bug reports, benchmark uploads: [docs/telemetry.md](../../docs/telemetry.md)) and stores it in one SQLite file. It serves anonymous totals to the website. It is one small Rust process, about 7 MB of memory when idle.
|
||||
This server receives what players choose to send (performance summaries, crash and bug reports, benchmark uploads: [docs/telemetry.md](../../docs/telemetry.md)) and stores it in one SQLite file. It serves anonymous totals to the website. It also keeps the live player count from the games' anonymous "I'm playing" pings, in memory only. It is one small Rust process, about 7 MB of memory when idle.
|
||||
|
||||
cargo run --release # in this folder: its own workspace, like the launcher's
|
||||
PORT=8080 HORIZON_TELEMETRY_DB=/data/telemetry.sqlite3 horizon-telemetry-server
|
||||
@@ -14,6 +14,8 @@ The launcher sends to it when `HORIZON_TELEMETRY_URL` is set (and to `DEFAULT_EN
|
||||
| `POST /v1/batch` | A zstd batch (`Content-Encoding: zstd`, at most 256 KiB, 1 MiB inflated, 64 items). Returns 204. Returns 400 for a bad batch, 413 for one too large, 415 if it is not zstd, 429 when over a limit. |
|
||||
| `DELETE /v1/installs/<id>` | Deletes everything that install sent. Its share of the daily totals is rebuilt at once. |
|
||||
| `GET /v1/aggregate?days=30` | The totals the website reads. Cached 10 minutes, with `Access-Control-Allow-Origin` set to the website. |
|
||||
| `POST /v1/live` | A game's "I'm playing" ping (JSON, at most 512 bytes). Returns 204. Returns 400 for a bad ping, 413 for one too large, 429 over the address's limit, 503 when it holds `LIVE_MAX_SESSIONS` already. |
|
||||
| `GET /v1/live` | How many play now, rounded: `playing`, `lan`, `versions`, `platforms`. Made again at most every 15 seconds, with the same `Access-Control-Allow-Origin`. |
|
||||
| `GET /healthz` | `ok` |
|
||||
|
||||
The totals are grouped per version, OS, GPU vendor and render distance. Each group has its sessions, install-days, hours played, the 14 frame-time buckets summed, and the mean p50 and p99 frame times. Groups with fewer than 5 install-days are left out. The totals also list crash counts per version and the newest 50 benchmark uploads.
|
||||
@@ -27,6 +29,8 @@ The totals are grouped per version, OS, GPU vendor and render distance. Each gro
|
||||
|
||||
Measured: **about 190 bytes per play session** on disk, indexes included (`tests/server.rs`, 10 000 sessions). 1 000 players playing twice a day for 180 days is about 70 MB.
|
||||
|
||||
The live count is a table in memory: a session ID, when it last pinged, its version, platform and LAN flag, about 100 bytes a session. A session counts while its last ping is under 2 minutes old and is forgotten after that. Nothing of it reaches the disk, so a restart empties it; it is whole again a minute later.
|
||||
|
||||
The day stored is the day the server received an item. The sender's IP address is never written down. The per-address rate limit keeps addresses in memory for an hour at most.
|
||||
|
||||
Raw rows are deleted after `HORIZON_TELEMETRY_RETENTION_DAYS` (180). The daily totals stay; they hold no install ID.
|
||||
@@ -43,6 +47,9 @@ All are environment variables:
|
||||
- `HORIZON_TELEMETRY_ITEMS_PER_DAY` per install (100)
|
||||
- `HORIZON_TELEMETRY_TRUST_PROXY=1` behind a proxy (the client's address is then the first of `X-Forwarded-For`)
|
||||
- `HORIZON_TELEMETRY_ALLOW_ORIGIN` (`https://playhorizon.dev`)
|
||||
- `HORIZON_TELEMETRY_LIVE_PER_HOUR` per address, pings and reads (600, bursts of 60: ten players behind one address)
|
||||
- `HORIZON_TELEMETRY_LIVE_WINDOW_SECONDS` (120)
|
||||
- `HORIZON_TELEMETRY_LIVE_MAX_SESSIONS` (200 000)
|
||||
|
||||
## Hosting
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
//! - `POST /v1/batch`: a zstd batch from the launcher, checked by `horizon_telemetry::batch`.
|
||||
//! - `DELETE /v1/installs/<id>`: everything an install sent goes.
|
||||
//! - `GET /v1/aggregate?days=30`: the anonymous totals the website reads, cached.
|
||||
//! - `POST /v1/live`: a game's anonymous "I'm playing" ping (`live`, in memory only).
|
||||
//! - `GET /v1/live`: how many play now, rounded, cached briefly.
|
||||
//! - `GET /healthz`.
|
||||
//!
|
||||
//! Abuse limits: a token bucket per IP address (kept in memory for an hour, never stored), a
|
||||
//! daily item cap per install, and the batch size limits. Raw rows expire after
|
||||
//! `retention_days`; the daily totals stay. One small process and one SQLite file: see
|
||||
//! `README.md` for running it.
|
||||
//! daily item cap per install, and the batch size limits. The live count has its own, larger
|
||||
//! bucket per address (several players can share one) and a cap on the sessions it holds. Raw
|
||||
//! rows expire after `retention_days`; the daily totals stay. One small process and one SQLite
|
||||
//! file: see `README.md` for running it.
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -21,8 +24,11 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use horizon_telemetry::batch;
|
||||
|
||||
pub mod live;
|
||||
pub mod store;
|
||||
|
||||
use horizon_telemetry::live::{MAX_PING_BYTES, Ping};
|
||||
use live::Live;
|
||||
use store::Store;
|
||||
|
||||
pub struct Config {
|
||||
@@ -38,6 +44,13 @@ pub struct Config {
|
||||
pub trust_proxy: bool,
|
||||
/// The website allowed to read the totals from a browser.
|
||||
pub allow_origin: String,
|
||||
/// Live count requests (pings and reads) an IP address may make an hour, with a burst of a
|
||||
/// tenth of it. A game pings once a minute, so the default lets ten play behind one address.
|
||||
pub live_per_hour: f64,
|
||||
/// How long a session counts after its last ping.
|
||||
pub live_window: Duration,
|
||||
/// Sessions the live count holds at most; a new one past that is refused.
|
||||
pub live_max_sessions: usize,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -49,13 +62,17 @@ impl Default for Config {
|
||||
items_per_day: 100,
|
||||
trust_proxy: false,
|
||||
allow_origin: "https://playhorizon.dev".into(),
|
||||
live_per_hour: 600.0,
|
||||
live_window: horizon_telemetry::live::WINDOW,
|
||||
live_max_sessions: 200_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// The defaults, with `HORIZON_TELEMETRY_RETENTION_DAYS`, `…_MIN_INSTALL_DAYS`,
|
||||
/// `…_REQUESTS_PER_HOUR`, `…_ITEMS_PER_DAY`, `…_TRUST_PROXY` (1) and `…_ALLOW_ORIGIN`.
|
||||
/// `…_REQUESTS_PER_HOUR`, `…_ITEMS_PER_DAY`, `…_TRUST_PROXY` (1), `…_ALLOW_ORIGIN`,
|
||||
/// `…_LIVE_PER_HOUR`, `…_LIVE_WINDOW_SECONDS` and `…_LIVE_MAX_SESSIONS`.
|
||||
pub fn from_env() -> Self {
|
||||
let var = |k: &str| std::env::var(format!("HORIZON_TELEMETRY_{k}")).ok();
|
||||
let d = Self::default();
|
||||
@@ -74,6 +91,15 @@ impl Config {
|
||||
.unwrap_or(d.items_per_day),
|
||||
trust_proxy: var("TRUST_PROXY").is_some_and(|v| v == "1"),
|
||||
allow_origin: var("ALLOW_ORIGIN").unwrap_or(d.allow_origin),
|
||||
live_per_hour: var("LIVE_PER_HOUR")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.live_per_hour),
|
||||
live_window: var("LIVE_WINDOW_SECONDS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.map_or(d.live_window, Duration::from_secs),
|
||||
live_max_sessions: var("LIVE_MAX_SESSIONS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.live_max_sessions),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,13 +108,21 @@ pub struct App {
|
||||
store: Mutex<Store>,
|
||||
config: Config,
|
||||
/// Tokens left and when they were counted, per client address.
|
||||
buckets: Mutex<HashMap<IpAddr, (f64, Instant)>>,
|
||||
buckets: Mutex<Buckets>,
|
||||
/// The same for the live count's requests.
|
||||
live_buckets: Mutex<Buckets>,
|
||||
/// Who is playing now (`live`).
|
||||
live: Mutex<Live>,
|
||||
/// `GET /v1/live`'s answer and when it was made.
|
||||
live_cache: Mutex<Option<(Instant, String)>>,
|
||||
/// `/v1/aggregate` answers by `days`, with when they were made.
|
||||
cache: Mutex<HashMap<u32, (Instant, String)>>,
|
||||
/// Today, in days since 1970 (a test moves it).
|
||||
today: Box<dyn Fn() -> u32 + Send + Sync>,
|
||||
}
|
||||
|
||||
type Buckets = HashMap<IpAddr, (f64, Instant)>;
|
||||
|
||||
const CACHE_FOR: Duration = Duration::from_secs(600);
|
||||
|
||||
fn status(code: StatusCode, message: &str) -> Response {
|
||||
@@ -105,10 +139,14 @@ impl App {
|
||||
config: Config,
|
||||
today: Box<dyn Fn() -> u32 + Send + Sync>,
|
||||
) -> Arc<Self> {
|
||||
let live = Live::new(config.live_window, config.live_max_sessions);
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(store),
|
||||
config,
|
||||
buckets: Mutex::default(),
|
||||
live_buckets: Mutex::default(),
|
||||
live: Mutex::new(live),
|
||||
live_cache: Mutex::default(),
|
||||
cache: Mutex::default(),
|
||||
today,
|
||||
})
|
||||
@@ -120,18 +158,16 @@ impl App {
|
||||
|
||||
/// Takes a token for `ip`; false when it has none left.
|
||||
fn allow(&self, ip: IpAddr) -> bool {
|
||||
let rate = self.config.requests_per_hour / 3600.0;
|
||||
let burst = (self.config.requests_per_hour / 10.0).max(1.0);
|
||||
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let now = Instant::now();
|
||||
let (tokens, at) = buckets.entry(ip).or_insert((burst, now));
|
||||
*tokens = (*tokens + now.duration_since(*at).as_secs_f64() * rate).min(burst);
|
||||
*at = now;
|
||||
if *tokens < 1.0 {
|
||||
return false;
|
||||
}
|
||||
*tokens -= 1.0;
|
||||
true
|
||||
take(&self.buckets, self.config.requests_per_hour, ip)
|
||||
}
|
||||
|
||||
/// [`Self::allow`] for the live count's requests.
|
||||
fn allow_live(&self, ip: IpAddr) -> bool {
|
||||
take(&self.live_buckets, self.config.live_per_hour, ip)
|
||||
}
|
||||
|
||||
fn live(&self) -> std::sync::MutexGuard<'_, Live> {
|
||||
self.live.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn client(&self, headers: &HeaderMap, peer: Option<SocketAddr>) -> IpAddr {
|
||||
@@ -160,14 +196,18 @@ impl App {
|
||||
self.store().newest_day(install).ok().flatten()
|
||||
}
|
||||
|
||||
/// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries forgotten.
|
||||
/// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries and live
|
||||
/// sessions forgotten.
|
||||
pub fn maintain(&self) -> rusqlite::Result<usize> {
|
||||
let oldest = (self.today)().saturating_sub(self.config.retention_days);
|
||||
let removed = self.store().expire(oldest)?;
|
||||
self.buckets
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.retain(|_, (_, at)| at.elapsed() < Duration::from_secs(3600));
|
||||
for buckets in [&self.buckets, &self.live_buckets] {
|
||||
buckets
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.retain(|_, (_, at)| at.elapsed() < Duration::from_secs(3600));
|
||||
}
|
||||
self.live().forget_expired(Instant::now());
|
||||
if removed > 0 {
|
||||
self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
}
|
||||
@@ -175,6 +215,23 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a token for `ip` from its bucket in `buckets` (`per_hour`, bursts of a tenth of it);
|
||||
/// false when it has none left.
|
||||
fn take(buckets: &Mutex<Buckets>, per_hour: f64, ip: IpAddr) -> bool {
|
||||
let rate = per_hour / 3600.0;
|
||||
let burst = (per_hour / 10.0).max(1.0);
|
||||
let mut buckets = buckets.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let now = Instant::now();
|
||||
let (tokens, at) = buckets.entry(ip).or_insert((burst, now));
|
||||
*tokens = (*tokens + now.duration_since(*at).as_secs_f64() * rate).min(burst);
|
||||
*at = now;
|
||||
if *tokens < 1.0 {
|
||||
return false;
|
||||
}
|
||||
*tokens -= 1.0;
|
||||
true
|
||||
}
|
||||
|
||||
fn peer(req: &Request) -> Option<SocketAddr> {
|
||||
req.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
@@ -278,18 +335,65 @@ async fn aggregate(State(app): State<Arc<App>>, Query(window): Query<Window>) ->
|
||||
}
|
||||
}
|
||||
};
|
||||
json_response(&app, json, CACHE_FOR.as_secs())
|
||||
}
|
||||
|
||||
/// `POST /v1/live`: one "I'm playing". 204, or 400 for a bad ping, 413 for one too large, 429
|
||||
/// over the address's limit, 503 when the table is full.
|
||||
async fn ping(State(app): State<Arc<App>>, req: Request) -> Response {
|
||||
if !app.allow_live(app.client(req.headers(), peer(&req))) {
|
||||
return status(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||
}
|
||||
let Ok(body) = axum::body::to_bytes(req.into_body(), MAX_PING_BYTES).await else {
|
||||
return status(StatusCode::PAYLOAD_TOO_LARGE, "a ping is at most 512 bytes");
|
||||
};
|
||||
let ping: Ping = match serde_json::from_slice(&body) {
|
||||
Ok(ping) => ping,
|
||||
Err(e) => return status(StatusCode::BAD_REQUEST, &e.to_string()),
|
||||
};
|
||||
if let Err(e) = ping.check() {
|
||||
return status(StatusCode::BAD_REQUEST, e);
|
||||
}
|
||||
match app.live().ping(&ping, Instant::now()) {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(live::Full) => status(StatusCode::SERVICE_UNAVAILABLE, "try again later"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /v1/live`: the rounded live count (`horizon_telemetry::live::Count`), made again at
|
||||
/// most every eighth of the window.
|
||||
async fn playing(State(app): State<Arc<App>>, req: Request) -> Response {
|
||||
if !app.allow_live(app.client(req.headers(), peer(&req))) {
|
||||
return status(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||
}
|
||||
let fresh_for = app.config.live_window / 8;
|
||||
let json = {
|
||||
let mut cache = app.live_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match &*cache {
|
||||
Some((at, json)) if at.elapsed() < fresh_for => json.clone(),
|
||||
_ => {
|
||||
let json =
|
||||
serde_json::to_string(&app.live().count(Instant::now())).unwrap_or_default();
|
||||
*cache = Some((Instant::now(), json.clone()));
|
||||
json
|
||||
}
|
||||
}
|
||||
};
|
||||
json_response(&app, json, fresh_for.as_secs())
|
||||
}
|
||||
|
||||
fn json_response(app: &App, json: String, max_age: u64) -> Response {
|
||||
let origin =
|
||||
HeaderValue::from_str(&app.config.allow_origin).unwrap_or(HeaderValue::from_static("*"));
|
||||
let cache = HeaderValue::from_str(&format!("public, max-age={max_age}"))
|
||||
.unwrap_or(HeaderValue::from_static("no-cache"));
|
||||
(
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json"),
|
||||
),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=600"),
|
||||
),
|
||||
(header::CACHE_CONTROL, cache),
|
||||
(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin),
|
||||
],
|
||||
json,
|
||||
@@ -300,6 +404,7 @@ async fn aggregate(State(app): State<Arc<App>>, Query(window): Query<Window>) ->
|
||||
pub fn router(app: Arc<App>) -> Router {
|
||||
Router::new()
|
||||
.route("/v1/batch", post(receive))
|
||||
.route("/v1/live", post(ping).get(playing))
|
||||
.route("/v1/installs/{id}", delete(forget))
|
||||
.route("/v1/aggregate", get(aggregate))
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Who is playing now, from the games' "I'm playing" pings (`horizon_telemetry::live`). Kept in
|
||||
//! memory only: a session ID, when it last pinged, its version, platform and LAN flag. A
|
||||
//! session counts while its last ping is under the window old and is forgotten after it. No
|
||||
//! address is kept, and nothing is written to disk, so a restart starts the count again (it
|
||||
//! is whole again a minute later).
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use horizon_telemetry::live::{Count, PLATFORMS, Ping, round};
|
||||
|
||||
/// Versions listed by name in a count; the rest are summed under `other`.
|
||||
const VERSIONS_SHOWN: usize = 12;
|
||||
|
||||
struct Seen {
|
||||
at: Instant,
|
||||
version: Box<str>,
|
||||
platform: u8,
|
||||
lan: bool,
|
||||
}
|
||||
|
||||
pub struct Live {
|
||||
sessions: HashMap<u128, Seen>,
|
||||
window: Duration,
|
||||
max_sessions: usize,
|
||||
}
|
||||
|
||||
/// The table is full and this session is new.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Full;
|
||||
|
||||
impl Live {
|
||||
pub fn new(window: Duration, max_sessions: usize) -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
window,
|
||||
max_sessions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `ping` (already checked) as seen at `now`.
|
||||
pub fn ping(&mut self, ping: &Ping, now: Instant) -> Result<(), Full> {
|
||||
let Ok(id) = u128::from_str_radix(&ping.session, 16) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.sessions.contains_key(&id) && self.sessions.len() >= self.max_sessions {
|
||||
self.forget_expired(now);
|
||||
if self.sessions.len() >= self.max_sessions {
|
||||
return Err(Full);
|
||||
}
|
||||
}
|
||||
let platform = PLATFORMS
|
||||
.iter()
|
||||
.position(|p| *p == ping.platform)
|
||||
.unwrap_or(PLATFORMS.len() - 1) as u8;
|
||||
self.sessions.insert(
|
||||
id,
|
||||
Seen {
|
||||
at: now,
|
||||
version: ping.version.as_str().into(),
|
||||
platform,
|
||||
lan: ping.lan,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops the sessions whose last ping is older than the window.
|
||||
pub fn forget_expired(&mut self, now: Instant) {
|
||||
let window = self.window;
|
||||
self.sessions
|
||||
.retain(|_, s| now.saturating_duration_since(s.at) < window);
|
||||
}
|
||||
|
||||
/// Who is playing at `now`, rounded.
|
||||
pub fn count(&mut self, now: Instant) -> Count {
|
||||
self.forget_expired(now);
|
||||
let mut versions: HashMap<&str, u64> = HashMap::new();
|
||||
let mut platforms = [0u64; PLATFORMS.len()];
|
||||
let mut lan = 0;
|
||||
for s in self.sessions.values() {
|
||||
*versions.entry(&s.version).or_default() += 1;
|
||||
platforms[usize::from(s.platform)] += 1;
|
||||
lan += u64::from(s.lan);
|
||||
}
|
||||
let mut by_count: Vec<(&str, u64)> = versions.into_iter().collect();
|
||||
by_count.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
|
||||
let mut shown = BTreeMap::new();
|
||||
let mut other = 0;
|
||||
for (i, (version, n)) in by_count.into_iter().enumerate() {
|
||||
if i < VERSIONS_SHOWN {
|
||||
shown.insert(version.to_owned(), round(n));
|
||||
} else {
|
||||
other += n;
|
||||
}
|
||||
}
|
||||
if other > 0 {
|
||||
*shown.entry("other".to_owned()).or_default() += round(other);
|
||||
}
|
||||
Count {
|
||||
playing: round(self.sessions.len() as u64),
|
||||
lan: round(lan),
|
||||
versions: shown,
|
||||
platforms: PLATFORMS
|
||||
.iter()
|
||||
.zip(platforms)
|
||||
.filter(|(_, n)| *n > 0)
|
||||
.map(|(p, n)| ((*p).to_owned(), round(n)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sessions held now, counted or not yet forgotten.
|
||||
pub fn len(&self) -> usize {
|
||||
self.sessions.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sessions.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ping(n: u32, version: &str, platform: &str, lan: bool) -> Ping {
|
||||
Ping {
|
||||
session: format!("{n:032x}"),
|
||||
version: version.into(),
|
||||
platform: platform.into(),
|
||||
lan,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_count_until_the_window_passes() {
|
||||
let t = Instant::now();
|
||||
let mut live = Live::new(Duration::from_secs(120), 100);
|
||||
live.ping(&ping(1, "0.2.0", "linux", false), t).unwrap();
|
||||
live.ping(&ping(2, "0.2.0", "macos", true), t).unwrap();
|
||||
// The same session again is still one.
|
||||
live.ping(
|
||||
&ping(1, "0.2.0", "linux", false),
|
||||
t + Duration::from_secs(60),
|
||||
)
|
||||
.unwrap();
|
||||
let count = live.count(t + Duration::from_secs(61));
|
||||
assert_eq!((count.playing, count.lan), (2, 1));
|
||||
assert_eq!(count.versions, BTreeMap::from([("0.2.0".into(), 2)]));
|
||||
assert_eq!(
|
||||
count.platforms,
|
||||
BTreeMap::from([("linux".into(), 1), ("macos".into(), 1)])
|
||||
);
|
||||
// Session 2 stopped pinging two minutes ago; session 1 pinged a minute later.
|
||||
let count = live.count(t + Duration::from_secs(150));
|
||||
assert_eq!((count.playing, count.lan), (1, 0));
|
||||
assert_eq!(live.len(), 1);
|
||||
assert_eq!(live.count(t + Duration::from_secs(181)), Count::default());
|
||||
assert!(live.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_table_takes_no_new_session_until_one_expires() {
|
||||
let t = Instant::now();
|
||||
let mut live = Live::new(Duration::from_secs(120), 2);
|
||||
live.ping(&ping(1, "0.2.0", "linux", false), t).unwrap();
|
||||
live.ping(&ping(2, "0.2.0", "linux", false), t).unwrap();
|
||||
assert_eq!(live.ping(&ping(3, "0.2.0", "linux", false), t), Err(Full));
|
||||
// A known session still refreshes.
|
||||
live.ping(
|
||||
&ping(1, "0.2.0", "linux", false),
|
||||
t + Duration::from_secs(100),
|
||||
)
|
||||
.unwrap();
|
||||
let later = t + Duration::from_secs(130);
|
||||
live.ping(&ping(3, "0.2.0", "linux", false), later).unwrap();
|
||||
assert_eq!(live.count(later).playing, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rare_versions_are_summed_and_counts_rounded() {
|
||||
let t = Instant::now();
|
||||
let mut live = Live::new(Duration::from_secs(120), 1000);
|
||||
for n in 0..40 {
|
||||
live.ping(&ping(n, "0.2.0", "windows", n % 4 == 0), t)
|
||||
.unwrap();
|
||||
}
|
||||
for n in 0..(VERSIONS_SHOWN as u32 + 3) {
|
||||
live.ping(&ping(100 + n, &format!("dev {n}"), "other", false), t)
|
||||
.unwrap();
|
||||
}
|
||||
let count = live.count(t);
|
||||
assert_eq!(count.playing, 55);
|
||||
assert_eq!(count.lan, 10);
|
||||
assert_eq!(count.versions["0.2.0"], 40);
|
||||
assert_eq!(count.versions["other"], 4);
|
||||
assert_eq!(count.versions.len(), VERSIONS_SHOWN + 1);
|
||||
assert_eq!(count.platforms["windows"], 40);
|
||||
assert_eq!(count.platforms["other"], 15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! The live count through its HTTP interface, and end to end with the game's ping and the
|
||||
//! launcher's read (`horizon_telemetry::live`).
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use horizon_telemetry::live::{self, Count, Ping};
|
||||
use horizon_telemetry_server::store::Store;
|
||||
use horizon_telemetry_server::{App, Config, router};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn app(config: Config) -> Arc<App> {
|
||||
App::new(Store::open(":memory:").unwrap(), config)
|
||||
}
|
||||
|
||||
fn quick() -> Config {
|
||||
Config {
|
||||
live_per_hour: 100_000.0,
|
||||
live_window: Duration::from_secs(1),
|
||||
trust_proxy: true,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ping(n: u32, version: &str, lan: bool) -> String {
|
||||
serde_json::to_string(&Ping {
|
||||
session: format!("{n:032x}"),
|
||||
version: version.into(),
|
||||
platform: "linux".into(),
|
||||
lan,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn send(app: &Arc<App>, req: Request<Body>) -> (StatusCode, axum::http::HeaderMap, String) {
|
||||
let res = router(app.clone()).oneshot(req).await.unwrap();
|
||||
let (code, headers) = (res.status(), res.headers().clone());
|
||||
let body = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(code, headers, String::from_utf8_lossy(&body).into_owned())
|
||||
}
|
||||
|
||||
async fn post(app: &Arc<App>, body: impl Into<Body>, from: &str) -> StatusCode {
|
||||
let req = Request::post("/v1/live")
|
||||
.header("x-forwarded-for", from)
|
||||
.body(body.into())
|
||||
.unwrap();
|
||||
send(app, req).await.0
|
||||
}
|
||||
|
||||
async fn count(app: &Arc<App>) -> Count {
|
||||
let req = Request::get("/v1/live")
|
||||
.header("x-forwarded-for", "198.51.100.1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (code, headers, body) = send(app, req).await;
|
||||
assert_eq!(code, StatusCode::OK, "{body}");
|
||||
assert_eq!(headers[header::CONTENT_TYPE], "application/json");
|
||||
assert_eq!(
|
||||
headers[header::ACCESS_CONTROL_ALLOW_ORIGIN],
|
||||
"https://playhorizon.dev"
|
||||
);
|
||||
serde_json::from_str(&body).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sessions_count_while_they_ping_and_expire_after() {
|
||||
let app = app(quick());
|
||||
assert_eq!(
|
||||
post(&app, ping(1, "0.2.0", false), "203.0.113.1").await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
post(&app, ping(2, "0.2.0", true), "203.0.113.2").await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
// A second ping from the same session is still one player.
|
||||
assert_eq!(
|
||||
post(&app, ping(1, "0.2.0", false), "203.0.113.1").await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let now = count(&app).await;
|
||||
assert_eq!((now.playing, now.lan), (2, 1));
|
||||
assert_eq!(now.versions["0.2.0"], 2);
|
||||
assert_eq!(now.platforms["linux"], 2);
|
||||
|
||||
// Session 1 keeps pinging; session 2 stops.
|
||||
for _ in 0..5 {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
post(&app, ping(1, "0.2.0", false), "203.0.113.1").await;
|
||||
}
|
||||
let later = count(&app).await;
|
||||
assert_eq!((later.playing, later.lan), (1, 0));
|
||||
tokio::time::sleep(Duration::from_millis(1200)).await;
|
||||
assert_eq!(count(&app).await, Count::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_pings_are_refused() {
|
||||
let app = app(quick());
|
||||
let from = "203.0.113.3";
|
||||
assert_eq!(post(&app, "not json", from).await, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
post(&app, ping(1, "<b>0.2</b>", false), from).await,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
let extra = r#"{"session":"00000000000000000000000000000001","version":"0.2.0","platform":"linux","lan":false,"name":"Steve"}"#;
|
||||
assert_eq!(post(&app, extra, from).await, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
post(&app, "x".repeat(4096), from).await,
|
||||
StatusCode::PAYLOAD_TOO_LARGE
|
||||
);
|
||||
assert_eq!(count(&app).await.playing, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_address_is_rate_limited_on_its_own_bucket() {
|
||||
let app = app(Config {
|
||||
live_per_hour: 60.0,
|
||||
trust_proxy: true,
|
||||
..Config::default()
|
||||
});
|
||||
// A burst of a tenth of the hour's 60, then 429.
|
||||
let mut codes = Vec::new();
|
||||
for n in 0..8 {
|
||||
codes.push(post(&app, ping(n, "0.2.0", false), "203.0.113.4").await);
|
||||
}
|
||||
assert_eq!(
|
||||
codes
|
||||
.iter()
|
||||
.filter(|c| **c == StatusCode::NO_CONTENT)
|
||||
.count(),
|
||||
6
|
||||
);
|
||||
assert_eq!(codes[7], StatusCode::TOO_MANY_REQUESTS);
|
||||
// Another address is not held back.
|
||||
assert_eq!(
|
||||
post(&app, ping(9, "0.2.0", false), "203.0.113.5").await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_full_table_refuses_new_sessions() {
|
||||
let app = app(Config {
|
||||
live_max_sessions: 2,
|
||||
..quick()
|
||||
});
|
||||
for n in 0..2 {
|
||||
assert_eq!(
|
||||
post(&app, ping(n, "0.2.0", false), "203.0.113.6").await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
post(&app, ping(2, "0.2.0", false), "203.0.113.6").await,
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
assert_eq!(
|
||||
post(&app, ping(1, "0.2.0", false), "203.0.113.6").await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_games_ping_and_the_launchers_read_reach_it_end_to_end() {
|
||||
let app = app(Config {
|
||||
live_window: Duration::from_secs(120),
|
||||
..quick()
|
||||
});
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let service = router(app.clone()).into_make_service_with_connect_info::<std::net::SocketAddr>();
|
||||
tokio::spawn(async move { axum::serve(listener, service).await.unwrap() });
|
||||
|
||||
let count = tokio::task::spawn_blocking(move || {
|
||||
let a = Ping::new(&live::session_id(), "0.2.0", false);
|
||||
let b = Ping::new(&live::session_id(), "dev 0.1.0", true);
|
||||
live::send(&url, &a)?;
|
||||
live::send(&url, &b)?;
|
||||
live::fetch(&url)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!((count.playing, count.lan), (2, 1));
|
||||
assert_eq!(count.versions.len(), 2);
|
||||
assert_eq!(count.platforms[live::platform()], 2);
|
||||
}
|
||||
@@ -13,12 +13,14 @@ serde_json = "1"
|
||||
ruzstd = "0.9"
|
||||
# The random install ID and outbox file names.
|
||||
getrandom = "0.3"
|
||||
# Sending, for the launcher; the ingest server builds without it.
|
||||
# Sending, for the launcher (and the game's "I'm playing" ping); the ingest server builds without it.
|
||||
ureq = { version = "2", default-features = false, features = ["tls"], optional = true }
|
||||
|
||||
[features]
|
||||
default = ["upload"]
|
||||
default = ["upload", "live"]
|
||||
upload = ["dep:ureq"]
|
||||
# The "I'm playing" ping and reading the live count (`live`), for the game and the launcher.
|
||||
live = ["dep:ureq"]
|
||||
|
||||
[[test]]
|
||||
name = "consent_gating"
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
//! connection. Items are small summaries (percentiles and histogram buckets, never raw streams),
|
||||
//! dated to the day, with no account, world, server or address in them ([`scrub`]); they leave
|
||||
//! in zstd batches ([`batch`]). `docs/telemetry.md` lists every field.
|
||||
//!
|
||||
//! The one exception is [`live`]: the anonymous "I'm playing" ping the game sends while it
|
||||
//! runs, for the live player count. It holds no install ID and nothing about the player.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub mod batch;
|
||||
pub mod consent;
|
||||
pub mod live;
|
||||
pub mod outbox;
|
||||
pub mod payload;
|
||||
pub mod scrub;
|
||||
@@ -29,8 +33,9 @@ pub const SCHEMA: u32 = 1;
|
||||
/// and until the player answers nothing new is kept or sent.
|
||||
pub const CONSENT_VERSION: u32 = 1;
|
||||
|
||||
/// The ingest server, or none while there isn't one: then nothing is ever sent and the outbox
|
||||
/// stays within its caps. `HORIZON_TELEMETRY_URL` overrides it (tests, a local server).
|
||||
/// The ingest server, or none while there isn't one: then nothing is ever sent (no ping either)
|
||||
/// and the outbox stays within its caps. `HORIZON_TELEMETRY_URL` overrides it (tests, a local
|
||||
/// server).
|
||||
pub const DEFAULT_ENDPOINT: Option<&str> = None;
|
||||
|
||||
/// Where items are sent, if anywhere.
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! The anonymous "I'm playing" ping, and the live count it feeds ("N playing now" in the
|
||||
//! launcher).
|
||||
//!
|
||||
//! Unlike everything else in this crate it does not wait for consent: Ludovic decided
|
||||
//! (2026-10-22) to count every player and tell them, in the launcher's first-run question, its
|
||||
//! Settings and the privacy page. So a ping holds only what the count needs: a random session ID
|
||||
//! made when the game starts and kept only in its memory (not the install ID), the Horizon
|
||||
//! version, the platform and whether a LAN or play-with-friends world is open. The game sends
|
||||
//! one every [`INTERVAL`]; the server counts a session while its last ping is under
|
||||
//! [`WINDOW`] old, keeps nothing on disk and serves the counts rounded ([`round`]).
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How often the game pings while it runs.
|
||||
pub const INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long a session counts after its last ping: two pings, so one lost on the way doesn't
|
||||
/// drop it.
|
||||
pub const WINDOW: Duration = Duration::from_secs(120);
|
||||
|
||||
/// The platforms a ping may name; anything else is `other`.
|
||||
pub const PLATFORMS: [&str; 4] = ["linux", "macos", "windows", "other"];
|
||||
|
||||
/// The largest ping body the server reads.
|
||||
pub const MAX_PING_BYTES: usize = 512;
|
||||
|
||||
const MAX_VERSION: usize = 40;
|
||||
|
||||
/// One "I'm playing", as it is sent: `{"session":"…","version":"…","platform":"…","lan":false}`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Ping {
|
||||
/// 128 random bits in hex, made when the game starts ([`session_id`]) and never written down.
|
||||
pub session: String,
|
||||
/// The release (`v2026.10.15`, `0.2.0`) or `dev 0.1.0` for a build from source.
|
||||
pub version: String,
|
||||
/// One of [`PLATFORMS`].
|
||||
pub platform: String,
|
||||
/// A world is open to LAN or to friends over the internet.
|
||||
pub lan: bool,
|
||||
}
|
||||
|
||||
impl Ping {
|
||||
pub fn new(session: &str, version: &str, lan: bool) -> Self {
|
||||
Self {
|
||||
session: session.to_owned(),
|
||||
version: version.to_owned(),
|
||||
platform: platform().to_owned(),
|
||||
lan,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the server refuses this ping, if it does.
|
||||
pub fn check(&self) -> Result<(), &'static str> {
|
||||
if !crate::consent::is_install_id(&self.session) {
|
||||
return Err("session: 32 lowercase hex digits");
|
||||
}
|
||||
let version_ok = !self.version.is_empty()
|
||||
&& self.version.len() <= MAX_VERSION
|
||||
&& self
|
||||
.version
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b" .+-_".contains(&b));
|
||||
if !version_ok {
|
||||
return Err("version: at most 40 letters, digits, spaces and .+-_");
|
||||
}
|
||||
if !PLATFORMS.contains(&self.platform.as_str()) {
|
||||
return Err("platform: linux, macos, windows or other");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A new session ID: 128 random bits in hex.
|
||||
pub fn session_id() -> String {
|
||||
crate::random_hex(16)
|
||||
}
|
||||
|
||||
/// This machine's entry in [`PLATFORMS`].
|
||||
pub fn platform() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
os @ ("linux" | "macos" | "windows") => os,
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// The live count, as `GET /v1/live` serves it: every number already [`round`]ed.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Count {
|
||||
/// Sessions that pinged within the window.
|
||||
pub playing: u64,
|
||||
/// Of those, the ones with a LAN or play-with-friends world open.
|
||||
pub lan: u64,
|
||||
/// Per version: the most played ones, the rest summed under `other`.
|
||||
pub versions: BTreeMap<String, u64>,
|
||||
/// Per platform.
|
||||
pub platforms: BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
/// Rounds a count for showing: exact under 10, then to the nearest 5 under 100, the nearest 10
|
||||
/// under 1000 and the nearest 100 above. Small numbers stay true; large ones don't pretend to
|
||||
/// a precision a two-minute window doesn't have.
|
||||
pub fn round(n: u64) -> u64 {
|
||||
let step = match n {
|
||||
0..10 => return n,
|
||||
10..100 => 5,
|
||||
100..1000 => 10,
|
||||
_ => 100,
|
||||
};
|
||||
(n + step / 2) / step * step
|
||||
}
|
||||
|
||||
/// Sends `ping` to `endpoint` (`https://host`, without a trailing slash).
|
||||
#[cfg(feature = "live")]
|
||||
pub fn send(endpoint: &str, ping: &Ping) -> Result<(), String> {
|
||||
agent()
|
||||
.post(&format!("{endpoint}/v1/live"))
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&serde_json::to_string(ping).map_err(|e| e.to_string())?)
|
||||
.map(drop)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The live count at `endpoint`.
|
||||
#[cfg(feature = "live")]
|
||||
pub fn fetch(endpoint: &str) -> Result<Count, String> {
|
||||
let body = agent()
|
||||
.get(&format!("{endpoint}/v1/live"))
|
||||
.call()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_string()
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&body).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "live")]
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ping() -> Ping {
|
||||
Ping::new(&session_id(), "v2026.10.15", false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_holds_only_the_four_fields() {
|
||||
let json = serde_json::to_value(Ping::new(&"a".repeat(32), "0.2.0", true)).unwrap();
|
||||
let mut keys: Vec<&str> = json
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(|k| k.as_str())
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys, ["lan", "platform", "session", "version"]);
|
||||
assert!(serde_json::to_string(&ping()).unwrap().len() < MAX_PING_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_ids_are_fresh_each_time() {
|
||||
let (a, b) = (session_id(), session_id());
|
||||
assert_ne!(a, b);
|
||||
assert!(ping().check().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_pings_are_refused() {
|
||||
let bad = |f: fn(&mut Ping)| {
|
||||
let mut p = ping();
|
||||
f(&mut p);
|
||||
p.check().is_err()
|
||||
};
|
||||
assert!(bad(|p| p.session = "ABC".into()));
|
||||
assert!(bad(|p| p.session = "G".repeat(32)));
|
||||
assert!(bad(|p| p.version = String::new()));
|
||||
assert!(bad(|p| p.version = "1".repeat(41)));
|
||||
assert!(bad(|p| p.version = "<script>".into()));
|
||||
assert!(bad(|p| p.platform = "haiku".into()));
|
||||
assert!(!bad(|p| p.version = "dev 0.1.0".into()));
|
||||
let extra = r#"{"session":"00000000000000000000000000000000","version":"0.2.0","platform":"linux","lan":false,"name":"Steve"}"#;
|
||||
assert!(serde_json::from_str::<Ping>(extra).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_round_more_as_they_grow() {
|
||||
let cases = [
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
(12, 10),
|
||||
(13, 15),
|
||||
(97, 95),
|
||||
(98, 100),
|
||||
(104, 100),
|
||||
(105, 110),
|
||||
(994, 990),
|
||||
(995, 1000),
|
||||
(1049, 1000),
|
||||
(1050, 1100),
|
||||
];
|
||||
for (n, shown) in cases {
|
||||
assert_eq!(round(n), shown, "{n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
/// threads, as vanilla's `MinecraftServer.spin` does for its server thread
|
||||
/// (`Thread.setPriority(8)` when there are more than four processors), so
|
||||
/// world generation and chunk encoding on every other core don't hold a tick
|
||||
/// back: user-interactive quality of service on macOS, above-normal priority
|
||||
/// on Windows. Nothing elsewhere: Linux lets an unprivileged thread lower its
|
||||
/// priority but not raise it (and the JVM leaves it there too). Whether the
|
||||
/// OS took it.
|
||||
/// back (the client asks the same for its render thread, so they don't hold
|
||||
/// a frame back either): user-interactive quality of service on macOS,
|
||||
/// above-normal priority on Windows. Nothing elsewhere: Linux lets an
|
||||
/// unprivileged thread lower its priority but not raise it (and the JVM
|
||||
/// leaves it there too). Whether the OS took it.
|
||||
pub fn favour_current() -> bool {
|
||||
if std::thread::available_parallelism().map_or(1, |n| n.get()) <= 4 {
|
||||
return false;
|
||||
@@ -40,30 +41,30 @@ fn favour() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Set once by a dedicated server, before its helper threads start: they
|
||||
/// then go behind its server thread (`behind_server`).
|
||||
/// Set once as the game starts, before its helper threads do: they then go
|
||||
/// behind the server thread and the render thread (`behind_server`).
|
||||
static HELPERS_BEHIND: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// How many nice levels a helper thread goes below the process on Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
const BEHIND: i32 = 10;
|
||||
|
||||
/// A dedicated server: from now on, the helper threads that call
|
||||
/// `behind_server` as they start go behind the server thread. Call it
|
||||
/// before they start.
|
||||
/// From now on, the helper threads that call `behind_server` as they start
|
||||
/// go behind the server thread (a dedicated server's or single player's)
|
||||
/// and the render thread. Call it before they start.
|
||||
pub fn put_helpers_behind() {
|
||||
HELPERS_BEHIND.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Called by a helper thread as it starts (world generation and lighting,
|
||||
/// a connection's packet writer): work the tick never waits for. In a
|
||||
/// dedicated server (`put_helpers_behind`) on Linux, where the server thread
|
||||
/// can't be raised (`favour_current`), the helper lowers itself instead,
|
||||
/// which an unprivileged thread may: 10 nice levels below the process, or,
|
||||
/// where nice runs out (a server started under nice 10 or more), the idle
|
||||
/// class, which runs the thread only when no ordinary thread wants its core.
|
||||
/// The server thread then gets a core when its tick starts instead of
|
||||
/// waiting behind them. Nothing elsewhere. Whether the OS took it.
|
||||
/// Called by a helper thread as it starts (world generation, lighting and
|
||||
/// meshing, a connection's packet writer): work no tick or frame waits for.
|
||||
/// Once `put_helpers_behind` was called, on Linux, where the server and
|
||||
/// render threads can't be raised (`favour_current`), the helper lowers
|
||||
/// itself instead, which an unprivileged thread may: 10 nice levels below
|
||||
/// the process, or, where nice runs out (a game started under nice 10 or
|
||||
/// more), the idle class, which runs the thread only when no ordinary thread
|
||||
/// wants its core. A tick or a frame then gets a core when it starts instead
|
||||
/// of waiting behind them. Nothing elsewhere. Whether the OS took it.
|
||||
pub fn behind_server() -> bool {
|
||||
HELPERS_BEHIND.load(Ordering::Relaxed) && behind()
|
||||
}
|
||||
|
||||
@@ -122,6 +122,9 @@ impl<L: LevelAccess + ?Sized> PlacementContext for LevelFeatureWorld<'_, '_, L>
|
||||
) -> Result<bool, PlacementQueryError> {
|
||||
Ok(self.sim.b.has_flag(state, state_flags::COLLISION_EMPTY))
|
||||
}
|
||||
fn can_occlude(&mut self, state: BlockStateId) -> Result<bool, PlacementQueryError> {
|
||||
Ok(self.sim.b.has_flag(state, state_flags::CAN_OCCLUDE))
|
||||
}
|
||||
/// BeehiveDecorator's bees go into the nest's block entity (the tree
|
||||
/// checked the nest is there).
|
||||
fn store_beehive_occupants(
|
||||
|
||||
@@ -816,6 +816,19 @@ impl PlacementContext for World<'_> {
|
||||
.is_face_sturdy(self, state, position, direction, SupportType::Center)
|
||||
.map_err(query)
|
||||
}
|
||||
fn can_occlude(&mut self, state: BlockStateId) -> Result<bool, PlacementQueryError> {
|
||||
if let Some(behaviours) = self.behaviours {
|
||||
return Ok(behaviours.has_flag(state, crate::level::data::state_flags::CAN_OCCLUDE));
|
||||
}
|
||||
let properties = &self.blocks()?.properties;
|
||||
let name = properties
|
||||
.registry()
|
||||
.state(state)
|
||||
.ok_or(PlacementQueryError::Unavailable)?
|
||||
.name
|
||||
.clone();
|
||||
Ok(properties.definition(&name).is_some_and(|d| d.can_occlude))
|
||||
}
|
||||
fn collision_full_block(
|
||||
&mut self,
|
||||
state: BlockStateId,
|
||||
|
||||
@@ -211,6 +211,13 @@ pub trait PlacementContext {
|
||||
fn water_at(&mut self, _position: [i32; 3]) -> Result<bool, PlacementQueryError> {
|
||||
Err(PlacementQueryError::Unavailable)
|
||||
}
|
||||
/// `BlockState.canOcclude()` of a state.
|
||||
fn can_occlude(
|
||||
&mut self,
|
||||
_state: crate::registry::BlockStateId,
|
||||
) -> Result<bool, PlacementQueryError> {
|
||||
Err(PlacementQueryError::Unavailable)
|
||||
}
|
||||
/// Multiface attachment: neighbor support, then empty-context collision.
|
||||
fn attachment_at(
|
||||
&mut self,
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
//! one pool element at a random spot of the chunk, at a height its
|
||||
//! placement finds in the noise columns under the element's corners and
|
||||
//! centre ([`StartContext::noise_column`]).
|
||||
//!
|
||||
//! Its PiecedStructure (the fountain) starts the same way, then lays out
|
||||
//! the element's jigsaw children with vanilla's jigsaw
|
||||
//! ([`super::jigsaw::add_children`]): `horizon:pieced` on the structure
|
||||
//! holds its `max_depth` and `max_distance_from_center`.
|
||||
use super::{
|
||||
BoundingBox, Rotation, StructureResources,
|
||||
jigsaw::{PoolElement, PoolPiece, Pools},
|
||||
jigsaw::{JigsawConfig, PoolElement, PoolPiece, Pools, add_children},
|
||||
start::{NoiseBlock, NoiseColumn, StartContext},
|
||||
};
|
||||
use crate::{
|
||||
@@ -51,6 +56,8 @@ impl Placement {
|
||||
pub struct Def {
|
||||
pub pool: String,
|
||||
pub placement: Placement,
|
||||
/// PiecedStructure's jigsaw: (max depth, max distance from the centre).
|
||||
pub pieced: Option<(i32, i32)>,
|
||||
}
|
||||
impl Def {
|
||||
pub fn decode(v: &Value) -> Result<Self, DecodeError> {
|
||||
@@ -61,11 +68,21 @@ impl Def {
|
||||
.ok_or(DecodeError::Field("formations template"))?
|
||||
.to_owned(),
|
||||
placement: Placement::decode(v["placement"].as_str().unwrap_or_default())?,
|
||||
pieced: match v.get("horizon:pieced") {
|
||||
None => None,
|
||||
Some(p) => {
|
||||
let int = |k: &'static str| {
|
||||
p[k].as_i64().map(|n| n as i32).ok_or(DecodeError::Field(k))
|
||||
};
|
||||
Some((int("max_depth")?, int("max_distance_from_center")?))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// SimpleStructure.findGenerationPoint: the start's position and piece.
|
||||
/// SimpleStructure.findGenerationPoint, or PiecedStructure's: the start's
|
||||
/// position and pieces.
|
||||
pub fn generate(
|
||||
def: &Def,
|
||||
context: &mut impl StartContext,
|
||||
@@ -74,7 +91,7 @@ pub fn generate(
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
chunk: ChunkPos,
|
||||
) -> Option<([i32; 3], PoolPiece)> {
|
||||
) -> Option<([i32; 3], Vec<PoolPiece>)> {
|
||||
let element = pools
|
||||
.get(&def.pool, resources, bindings)?
|
||||
.random_template(random);
|
||||
@@ -88,7 +105,7 @@ pub fn generate(
|
||||
let height = find_height(def.placement, context, b, random)?;
|
||||
let ground = element.ground_level_delta();
|
||||
let y = height - ground + 1;
|
||||
if y < context.min_y() {
|
||||
if def.pieced.is_none() && y < context.min_y() {
|
||||
return None;
|
||||
}
|
||||
let piece = PoolPiece {
|
||||
@@ -101,7 +118,46 @@ pub fn generate(
|
||||
// LiquidSettings.APPLY_WATERLOGGING.
|
||||
waterlogging: true,
|
||||
};
|
||||
Some(([x, y, z], piece))
|
||||
let Some((max_depth, distance)) = def.pieced else {
|
||||
return Some(([x, y, z], vec![piece]));
|
||||
};
|
||||
// PiecedStructure.addPieces: the start sits at the element's centre, one
|
||||
// above the height found, and its children may fill the box of
|
||||
// `distance` around it (not clamped to the world's height) less its own.
|
||||
let (cx, cy, cz) = (
|
||||
(b.max[0] + b.min[0]) / 2,
|
||||
height + 1,
|
||||
(b.max[2] + b.min[2]) / 2,
|
||||
);
|
||||
let mut pieces = vec![piece];
|
||||
if max_depth > 0 {
|
||||
let config = JigsawConfig {
|
||||
start_pool: &def.pool,
|
||||
start_jigsaw: None,
|
||||
max_depth,
|
||||
use_expansion_hack: false,
|
||||
project_to: None,
|
||||
max_distance: [distance, distance],
|
||||
aliases: &Default::default(),
|
||||
padding: [0, 0],
|
||||
waterlogging: true,
|
||||
};
|
||||
let container = BoundingBox::new(
|
||||
[cx - distance, cy - distance, cz - distance],
|
||||
[cx + distance, cy + distance, cz + distance],
|
||||
);
|
||||
add_children(
|
||||
&config,
|
||||
context,
|
||||
pools,
|
||||
resources,
|
||||
bindings,
|
||||
random,
|
||||
&mut pieces,
|
||||
container,
|
||||
);
|
||||
}
|
||||
Some(([cx, cy, cz], pieces))
|
||||
}
|
||||
|
||||
/// The four corners (min x min z, min x max z, max x max z, max x min z)
|
||||
@@ -335,6 +391,15 @@ mod tests {
|
||||
);
|
||||
assert!(Placement::decode("floating").is_err());
|
||||
let def = Def::decode(&serde_json::json!({"template": "a:b", "placement": "surface"}));
|
||||
assert_eq!(def.unwrap().placement, Placement::Surface);
|
||||
let def = def.unwrap();
|
||||
assert_eq!((def.placement, def.pieced), (Placement::Surface, None));
|
||||
let pieced = Def::decode(
|
||||
&serde_json::json!({"template": "a:b", "placement": "surface",
|
||||
"horizon:pieced": {"max_depth": 2, "max_distance_from_center": 116}}),
|
||||
);
|
||||
assert_eq!(pieced.unwrap().pieced, Some((2, 116)));
|
||||
let broken = serde_json::json!({"template": "a:b", "placement": "surface",
|
||||
"horizon:pieced": {"max_depth": 2}});
|
||||
assert!(Def::decode(&broken).is_err());
|
||||
}
|
||||
}
|
||||
@@ -690,25 +690,53 @@ pub fn assemble(
|
||||
let [h, v] = config.max_distance;
|
||||
let low_y = (cy - v).max(context.min_y() + config.padding[0]);
|
||||
let high_y = (cy + v + 1).min(context.max_y() + 1 - config.padding[1]);
|
||||
let free = Rc::new(RefCell::new(Free {
|
||||
container: BoundingBox::new([cx - h, low_y, cz - h], [cx + h, high_y - 1, cz + h]),
|
||||
removed: vec![pieces[0].bounding_box],
|
||||
}));
|
||||
let mut placer = Placer {
|
||||
let container = BoundingBox::new([cx - h, low_y, cz - h], [cx + h, high_y - 1, cz + h]);
|
||||
add_children(
|
||||
config,
|
||||
context,
|
||||
pools,
|
||||
resources,
|
||||
bindings,
|
||||
pieces: &mut pieces,
|
||||
queue: BTreeMap::new(),
|
||||
};
|
||||
placer.children(0, free, 0, context, random);
|
||||
while let Some(state) = placer.next() {
|
||||
placer.children(state.piece, state.free, state.depth, context, random);
|
||||
}
|
||||
random,
|
||||
&mut pieces,
|
||||
container,
|
||||
);
|
||||
}
|
||||
Some(([cx, cy, cz], pieces))
|
||||
}
|
||||
|
||||
/// The inner JigsawPlacement.addPieces: lays out the children of
|
||||
/// `pieces[0]`, a start piece already placed, to `config.max_depth`,
|
||||
/// within `container` (inclusive) less the start's box. A structure with
|
||||
/// its own start (Formations' pieced one) reaches the jigsaw layout here.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn add_children(
|
||||
config: &JigsawConfig<'_>,
|
||||
context: &mut impl JigsawContext,
|
||||
pools: &Pools,
|
||||
resources: &StructureResources,
|
||||
bindings: &FeatureBlockBindings<'_>,
|
||||
random: &mut impl RandomSource,
|
||||
pieces: &mut Vec<PoolPiece>,
|
||||
container: BoundingBox,
|
||||
) {
|
||||
let free = Rc::new(RefCell::new(Free {
|
||||
container,
|
||||
removed: vec![pieces[0].bounding_box],
|
||||
}));
|
||||
let mut placer = Placer {
|
||||
config,
|
||||
pools,
|
||||
resources,
|
||||
bindings,
|
||||
pieces,
|
||||
queue: BTreeMap::new(),
|
||||
};
|
||||
placer.children(0, free, 0, context, random);
|
||||
while let Some(state) = placer.next() {
|
||||
placer.children(state.piece, state.free, state.depth, context, random);
|
||||
}
|
||||
}
|
||||
struct Placer<'a, 'p> {
|
||||
config: &'a JigsawConfig<'a>,
|
||||
pools: &'a Pools,
|
||||
|
||||
@@ -331,6 +331,9 @@ pub enum Processor {
|
||||
/// `horizon:waterlog_in_world_water` (Repurposed Structures'): blocks
|
||||
/// are waterlogged where the world holds water, dry where it does not.
|
||||
WaterlogInWorldWater,
|
||||
/// `horizon:mineshaft_sky_view` (Repurposed Structures' mineshafts):
|
||||
/// nothing above the ocean floor but rails on solid ground.
|
||||
MineshaftSkyView,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ProcessorList(pub Vec<Processor>);
|
||||
@@ -827,6 +830,7 @@ impl Processor {
|
||||
}
|
||||
"horizon:waterlog" => Self::Waterlog,
|
||||
"horizon:waterlog_in_world_water" => Self::WaterlogInWorldWater,
|
||||
"horizon:mineshaft_sky_view" => Self::MineshaftSkyView,
|
||||
"horizon:keep_world_block" => {
|
||||
Self::KeepWorldBlock(Box::new(sea_level::KeepWorldBlock::decode(value)?))
|
||||
}
|
||||
@@ -1219,6 +1223,9 @@ fn process_block<W: PlacementContext>(
|
||||
settings,
|
||||
);
|
||||
}
|
||||
Processor::MineshaftSkyView => {
|
||||
return sea_level::MineshaftSkyView::process(world, resources.registry, info);
|
||||
}
|
||||
Processor::Pick(pick) => match name(info.state) == pick.block {
|
||||
true => {
|
||||
let mut out = settings.with_random(info.pos, |r| pick.apply(info.clone(), r));
|
||||
|
||||
@@ -13,10 +13,14 @@
|
||||
//! And from Repurposed Structures':
|
||||
//! - `horizon:waterlog_in_world_water`: a block that can be waterlogged is
|
||||
//! where the world holds water and is dry where it does not (its
|
||||
//! WaterlogWhenReplacingWaterProcessor).
|
||||
//! WaterlogWhenReplacingWaterProcessor);
|
||||
//! - `horizon:mineshaft_sky_view`: its mineshafts are not built above the
|
||||
//! ocean floor, but keep their rails on solid ground (its
|
||||
//! MineshaftSkyViewProcessor).
|
||||
use super::{PlaceSettings, ProcessResult, pillars::outside_chunk};
|
||||
use crate::{
|
||||
fluid::{FluidKind, FluidState},
|
||||
heightmap::HeightmapType,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
worldgen::{DecodeError, placement::PlacementContext, structure::BlockInfo},
|
||||
};
|
||||
@@ -152,11 +156,7 @@ impl WaterlogInWorldWater {
|
||||
if !state.properties.contains_key("waterlogged") || outside_chunk(settings, info.pos) {
|
||||
return Ok(Some(info));
|
||||
}
|
||||
let here = world.block_state(info.pos)?;
|
||||
let water = registry
|
||||
.state(here)
|
||||
.and_then(|s| FluidState::from_block(s).ok())
|
||||
.is_some_and(|f| f.kind().same_family(FluidKind::Water));
|
||||
let water = world_water(world, registry, info.pos)?;
|
||||
// The mod's height check (min to max build height) holds for every
|
||||
// block of the chunk.
|
||||
if water && world.inside_build_height(info.pos)? {
|
||||
@@ -172,6 +172,56 @@ impl WaterlogInWorldWater {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the world's block at `pos` holds water (still or flowing, or a
|
||||
/// waterlogged block): `getFluidState().is(FluidTags.WATER)`.
|
||||
fn world_water<W: PlacementContext>(
|
||||
world: &mut W,
|
||||
registry: &BlockRegistry,
|
||||
pos: [i32; 3],
|
||||
) -> Result<bool, crate::worldgen::placement::PlacementQueryError> {
|
||||
let here = world.block_state(pos)?;
|
||||
Ok(registry
|
||||
.state(here)
|
||||
.and_then(|s| FluidState::from_block(s).ok())
|
||||
.is_some_and(|f| f.kind().same_family(FluidKind::Water)))
|
||||
}
|
||||
|
||||
/// `horizon:mineshaft_sky_view`: MineshaftSkyViewProcessor. A plain rail
|
||||
/// whose world block below can occlude is placed, waterlogged where the
|
||||
/// world holds water; any other block at or above the world's
|
||||
/// `OCEAN_FLOOR_WG` height in its column is not placed, so a mineshaft cut
|
||||
/// by a valley or a ravine is not built in the open.
|
||||
pub struct MineshaftSkyView;
|
||||
impl MineshaftSkyView {
|
||||
pub(super) fn process<W: PlacementContext>(
|
||||
world: &mut W,
|
||||
registry: &BlockRegistry,
|
||||
info: BlockInfo,
|
||||
) -> ProcessResult {
|
||||
let [x, y, z] = info.pos;
|
||||
let rail = registry
|
||||
.state(info.state)
|
||||
.filter(|s| s.name == "minecraft:rail");
|
||||
if let Some(rail) = rail {
|
||||
let below = world.block_state([x, y - 1, z])?;
|
||||
if world.can_occlude(below)? {
|
||||
let mut set = rail.clone();
|
||||
let water = world_water(world, registry, info.pos)?;
|
||||
set.properties
|
||||
.insert("waterlogged".into(), water.to_string());
|
||||
return Ok(Some(BlockInfo {
|
||||
state: registry.id(&set).unwrap_or(info.state),
|
||||
..info
|
||||
}));
|
||||
}
|
||||
}
|
||||
if y >= world.height(HeightmapType::OceanFloorWg, x, z)? {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(info))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -198,8 +248,19 @@ mod tests {
|
||||
fn min_y(&mut self) -> Result<i32, PlacementQueryError> {
|
||||
Ok(-64)
|
||||
}
|
||||
fn height(&mut self, _: HeightmapType, _: i32, _: i32) -> Result<i32, PlacementQueryError> {
|
||||
Err(PlacementQueryError::Unavailable)
|
||||
/// The ocean floor is at 64 everywhere.
|
||||
fn height(
|
||||
&mut self,
|
||||
kind: HeightmapType,
|
||||
_: i32,
|
||||
_: i32,
|
||||
) -> Result<i32, PlacementQueryError> {
|
||||
assert_eq!(kind, HeightmapType::OceanFloorWg);
|
||||
Ok(64)
|
||||
}
|
||||
/// Only stone can occlude.
|
||||
fn can_occlude(&mut self, state: BlockStateId) -> Result<bool, PlacementQueryError> {
|
||||
Ok(state == BlockStateId(6))
|
||||
}
|
||||
fn sea_level(&mut self) -> Result<i32, PlacementQueryError> {
|
||||
Ok(63)
|
||||
@@ -226,12 +287,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// 0 air, 1 cave air, 2 water, 3/4 slab dry/wet, 5 purpur slab, 6 stone.
|
||||
// 0 air, 1 cave air, 2 water, 3/4 slab dry/wet, 5 purpur slab, 6 stone,
|
||||
// 7/8 rail dry/wet.
|
||||
fn registry() -> BlockRegistry {
|
||||
let row = |name: &str, props: Value| json!([name, false, false, "", {"id": name, "properties": props}]);
|
||||
let facts = json!({"defaults": {"minecraft:air": 0, "minecraft:cave_air": 1,
|
||||
"minecraft:water": 2, "minecraft:prismarine_slab": 3, "minecraft:purpur_slab": 5,
|
||||
"minecraft:stone": 6},
|
||||
"minecraft:stone": 6, "minecraft:rail": 7},
|
||||
"states": [
|
||||
["minecraft:air", false, false, "", "minecraft:air"],
|
||||
["minecraft:cave_air", false, false, "", "minecraft:cave_air"],
|
||||
@@ -239,7 +301,9 @@ mod tests {
|
||||
row("minecraft:prismarine_slab", json!({"waterlogged": "false"})),
|
||||
row("minecraft:prismarine_slab", json!({"waterlogged": "true"})),
|
||||
["minecraft:purpur_slab", false, false, "", "minecraft:purpur_slab"],
|
||||
["minecraft:stone", false, false, "", "minecraft:stone"]]});
|
||||
["minecraft:stone", false, false, "", "minecraft:stone"],
|
||||
row("minecraft:rail", json!({"shape": "north_south", "waterlogged": "false"})),
|
||||
row("minecraft:rail", json!({"shape": "north_south", "waterlogged": "true"}))]});
|
||||
BlockRegistry::from_facts_json(facts.to_string().as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
@@ -356,4 +420,29 @@ mod tests {
|
||||
assert_eq!(run(&mut sea, info(6, [3, 40, 3])), BlockStateId(6));
|
||||
assert_eq!(run(&mut sea, info(4, [16, 40, 3])), BlockStateId(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mineshafts_stop_at_the_ocean_floor_but_keep_grounded_rails() {
|
||||
let registry = registry();
|
||||
let mut sea = Sea {
|
||||
world: BlockStateId(6),
|
||||
marked: vec![],
|
||||
ticks: vec![],
|
||||
};
|
||||
let run = |sea: &mut Sea, i: BlockInfo| {
|
||||
MineshaftSkyView::process(sea, ®istry, i)
|
||||
.unwrap()
|
||||
.map(|i| i.state)
|
||||
};
|
||||
// Over stone a rail stays, above the floor too, dry as the world is.
|
||||
assert_eq!(run(&mut sea, info(8, [3, 80, 3])), Some(BlockStateId(7)));
|
||||
// Anything else stops at the floor (64).
|
||||
assert_eq!(run(&mut sea, info(6, [3, 63, 3])), Some(BlockStateId(6)));
|
||||
assert_eq!(run(&mut sea, info(6, [3, 64, 3])), None);
|
||||
assert_eq!(run(&mut sea, info(0, [3, 90, 3])), None);
|
||||
// Over water a rail is like any block.
|
||||
sea.world = BlockStateId(2);
|
||||
assert_eq!(run(&mut sea, info(7, [3, 70, 3])), None);
|
||||
assert_eq!(run(&mut sea, info(8, [3, 40, 3])), Some(BlockStateId(8)));
|
||||
}
|
||||
}
|
||||
@@ -149,13 +149,24 @@ pub struct StructureSet {
|
||||
/// and none within `chunk_count` chunks of any of several sets
|
||||
/// (`"horizon:exclusion_zone"`: `other_sets`, `chunk_count`).
|
||||
min_distance: Option<i64>,
|
||||
avoid: Option<(Vec<String>, i32)>,
|
||||
avoid: Option<Avoid>,
|
||||
/// `"horizon:ring_sections"`: starts only in odd rings around the origin.
|
||||
rings: Option<RingSections>,
|
||||
/// `"horizon:avoid_biomes"`: no start with one of these biomes near.
|
||||
avoid_biomes: Option<AvoidBiomes>,
|
||||
}
|
||||
|
||||
/// `"horizon:exclusion_zone"`: no start within `chunk_count` chunks of a
|
||||
/// start of any of `sets`. With `"their_zones": false` those sets' own
|
||||
/// exclusion zones are not checked (Mo' Structures' ModStructurePlacement
|
||||
/// tests the sets it avoids by their spread and frequency alone).
|
||||
#[derive(Debug, Clone)]
|
||||
struct Avoid {
|
||||
sets: Vec<String>,
|
||||
chunk_count: i32,
|
||||
their_zones: bool,
|
||||
}
|
||||
|
||||
/// YUNG's Better Strongholds' placement (BetterStrongholdsPlacement
|
||||
/// .isPlacementChunk): the world is cut into rings `thickness` chunks wide,
|
||||
/// the first one starting `distance_to_first_ring` chunks out; a start is
|
||||
@@ -444,16 +455,15 @@ impl StructureRegistry {
|
||||
min_distance: p
|
||||
.get("horizon:min_distance_from_world_origin")
|
||||
.and_then(Value::as_i64),
|
||||
avoid: p.get("horizon:exclusion_zone").map(|z| {
|
||||
(
|
||||
z["other_sets"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| Some(s.as_str()?.to_owned()))
|
||||
.collect(),
|
||||
z["chunk_count"].as_i64().unwrap_or(0) as i32,
|
||||
)
|
||||
avoid: p.get("horizon:exclusion_zone").map(|z| Avoid {
|
||||
sets: z["other_sets"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| Some(s.as_str()?.to_owned()))
|
||||
.collect(),
|
||||
chunk_count: z["chunk_count"].as_i64().unwrap_or(0) as i32,
|
||||
their_zones: z["their_zones"].as_bool().unwrap_or(true),
|
||||
}),
|
||||
rings: p.get("horizon:ring_sections").map(RingSections::decode),
|
||||
avoid_biomes: p
|
||||
@@ -534,6 +544,19 @@ impl StructureRegistry {
|
||||
x: i32,
|
||||
z: i32,
|
||||
rings: &Rings,
|
||||
) -> bool {
|
||||
self.structure_chunk(set, seed, x, z, rings, true)
|
||||
}
|
||||
/// [`Self::is_structure_chunk`]; without the set's exclusion zones when
|
||||
/// `zones` is false.
|
||||
fn structure_chunk(
|
||||
&self,
|
||||
set: &StructureSet,
|
||||
seed: i64,
|
||||
x: i32,
|
||||
z: i32,
|
||||
rings: &Rings,
|
||||
zones: bool,
|
||||
) -> bool {
|
||||
let placed = match set.placement {
|
||||
Placement::RandomSpread {
|
||||
@@ -576,24 +599,28 @@ impl StructureRegistry {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some((others, range)) = &set.avoid {
|
||||
if others
|
||||
.iter()
|
||||
.any(|other| self.has_start_in_range(other, seed, x, z, *range, rings))
|
||||
{
|
||||
if let Some(avoid) = set.avoid.as_ref().filter(|_| zones) {
|
||||
if avoid.sets.iter().any(|other| {
|
||||
let range = avoid.chunk_count;
|
||||
self.has_start_in_range(other, seed, x, z, range, rings, avoid.their_zones)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if set.frequency < 1. && !reduce(set.reduction, seed, set.salt, x, z, set.frequency) {
|
||||
return false;
|
||||
}
|
||||
match &set.exclusion {
|
||||
Some((other, range)) => !self.has_start_in_range(other, seed, x, z, *range, rings),
|
||||
match set.exclusion.as_ref().filter(|_| zones) {
|
||||
Some((other, range)) => {
|
||||
!self.has_start_in_range(other, seed, x, z, *range, rings, true)
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
/// ChunkGeneratorStructureState.hasStructureChunkInRange: whether set
|
||||
/// `other` places a structure within `range` chunks of (`x`, `z`).
|
||||
/// `other` places a structure within `range` chunks of (`x`, `z`),
|
||||
/// checking its own exclusion zones when `zones` is true.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn has_start_in_range(
|
||||
&self,
|
||||
other: &str,
|
||||
@@ -602,12 +629,14 @@ impl StructureRegistry {
|
||||
z: i32,
|
||||
range: i32,
|
||||
rings: &Rings,
|
||||
zones: bool,
|
||||
) -> bool {
|
||||
let Some(other) = self.sets.iter().find(|s| s.name == other) else {
|
||||
return false;
|
||||
};
|
||||
(x - range..=x + range).any(|tx| {
|
||||
(z - range..=z + range).any(|tz| self.is_structure_chunk(other, seed, tx, tz, rings))
|
||||
(z - range..=z + range)
|
||||
.any(|tz| self.structure_chunk(other, seed, tx, tz, rings, zones))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1144,7 +1173,7 @@ fn try_generate(
|
||||
(position, legacy(vec![piece]))
|
||||
}
|
||||
StructureKind::Formations(formations) => {
|
||||
let (position, piece) = super::formations::generate(
|
||||
let (position, pieces) = super::formations::generate(
|
||||
formations,
|
||||
context,
|
||||
®istry.pools,
|
||||
@@ -1153,7 +1182,7 @@ fn try_generate(
|
||||
&mut random,
|
||||
chunk,
|
||||
)?;
|
||||
(position, vec![Piece::Pool(piece)])
|
||||
(position, pieces.into_iter().map(Piece::Pool).collect())
|
||||
}
|
||||
StructureKind::Unsupported(_) => return None,
|
||||
};
|
||||
@@ -1429,6 +1458,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusion_zones_may_skip_the_other_sets_own() {
|
||||
let set = |avoid: Value| {
|
||||
json!({"structures": [{"structure": "t:s", "weight": 1}],
|
||||
"placement": {"type": "minecraft:random_spread", "spacing": 1, "separation": 0,
|
||||
"salt": 1, "horizon:exclusion_zone": avoid}})
|
||||
};
|
||||
let r = registry(
|
||||
&[("t:s", json!({"type": "minecraft:igloo", "biomes": []}))],
|
||||
&[
|
||||
("t:c", set(json!({"other_sets": [], "chunk_count": 0}))),
|
||||
// Every chunk is b's spread chunk, and c keeps b out of all.
|
||||
("t:b", set(json!({"other_sets": ["t:c"], "chunk_count": 1}))),
|
||||
(
|
||||
"t:full",
|
||||
set(json!({"other_sets": ["t:b"], "chunk_count": 1})),
|
||||
),
|
||||
(
|
||||
"t:spread",
|
||||
set(json!({"other_sets": ["t:b"], "chunk_count": 1, "their_zones": false})),
|
||||
),
|
||||
],
|
||||
);
|
||||
let chunk = |name: &str| {
|
||||
let s = r.sets.iter().find(|s| s.name == name).unwrap();
|
||||
r.is_structure_chunk(s, 0, 5, -3, &Rings::new())
|
||||
};
|
||||
assert!(!chunk("t:b"));
|
||||
assert!(
|
||||
chunk("t:full"),
|
||||
"b places nothing once its own zone is checked"
|
||||
);
|
||||
assert!(!chunk("t:spread"), "b's spread alone is in range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_sections_keep_the_odd_rings() {
|
||||
let rings = RingSections::decode(
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ python3 tools/graphics_bench.py --compare baseline-4080 mine-gpu # markdown ta
|
||||
|
||||
vscreen is a headless compositor whose frames are software-rendered (lavapipe), so its frame times measure the CPU, rasterisation included. `--offscreen` (`horizon-client --offscreen`) renders into a texture on the most powerful GPU instead of the window, which measures the RTX 4080 under the same compositor. `--distances`, `--scenes` and `--build` pick a subset or another build. Results go to `benchmarks/graphics/<label>.json`, with the load average before and after: the desktop is shared, and other sessions' builds move the numbers.
|
||||
|
||||
Two tools find where a slow frame goes: `HORIZON_SPIKES=MS` prints the timed spans (ticks by system, streaming, uploads, saving, renderer) of every frame slower than MS milliseconds, and a frame-pointer build (`RUSTFLAGS="-C force-frame-pointers=yes"`) profiles with `perf record --call-graph fp`.
|
||||
Two tools find where a slow frame goes: `HORIZON_SPIKES=MS` prints the timed spans (ticks by system, streaming, uploads, saving, renderer) of every frame slower than MS milliseconds, with the render thread's CPU time in the frame and, after a span that mostly waited (for the GPU, a lock or a core), its CPU time (`submit 30.2/0.1`), and a frame-pointer build (`RUSTFLAGS="-C force-frame-pointers=yes"`) profiles with `perf record --call-graph fp`.
|
||||
|
||||
## Results
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ plan are in [docs/server.md](https://github.com/ludovic111/horizon/blob/main/doc
|
||||
|
||||
## 1. Install
|
||||
|
||||
Linux (x86-64) and macOS (Apple Silicon):
|
||||
Linux (x86-64) and macOS (Apple Silicon; Intel from the next release):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
|
||||
+75
-3
@@ -444,6 +444,18 @@ Two cases the oracle fills in:
|
||||
also spreads its structures 1.65 times as far apart as its files say, which carry now
|
||||
applies. MVS went from 829 starts to 90 in a 128×128-chunk square (seed 1234): 294 from the
|
||||
spacing, the rest from the start checks.
|
||||
- **Mo' Structures** (LGPL-3.0; 38 files). Its `mostructures:generic` is vanilla's jigsaw
|
||||
that starts only where the terrain under the 5×5 chunks around it varies by no more than
|
||||
its `heightRange`: carry writes `minecraft:jigsaw` with `horizon:start_checks`
|
||||
(`terrain_radius` 2, `terrain_range`). Its placement `mostructures:type` takes spacing and
|
||||
separation from the mod's config, not its files; carry writes the config's defaults
|
||||
(keyed by `config_key`) and the sets it avoids within 3 chunks as `horizon:exclusion_zone`
|
||||
with `"their_zones": false`: like the mod, the game tests those sets by their spread and
|
||||
frequency alone, not by their own exclusion zones. Its two block-ageing processors
|
||||
(`jungle_rot_processor`, `simple_cobblestone`, 3 uses) are still dropped, as is its
|
||||
mixin that keeps lakes out of its structures. Over chunks -64..63 that adds 13 starts at
|
||||
seed 1234 (pirate ships, churches, a tavern, the castle in the sky...) and 16 at seed 2,
|
||||
and every other start stays as it was.
|
||||
- **Processors written in Java** are taken out of their lists (#822). The game, like
|
||||
vanilla, refuses a list holding a type it does not know, and with it every pool naming the
|
||||
list. The structures now generate undecorated (no pillars under them, spawners as the
|
||||
@@ -482,7 +494,11 @@ Two cases the oracle fills in:
|
||||
plus the name's hash, at the block, piece or structure). In a CTOV plains village at seed
|
||||
1234 the farms now grow carrots, potatoes and beetroot besides wheat, and 1,655
|
||||
cobblestone blocks become paths, grass, mossy cobblestone and planks. No Lithostitched processor is
|
||||
dropped now (130 before), only CTOV's compat hooks for other mods inside them (142 copies).
|
||||
dropped now (130 before). CTOV's compat hooks for other mods inside them (its
|
||||
`modular_compat`, 142 copies) run a list of their own only when the mod they name is
|
||||
loaded: carry writes that list in when the mod is ported along and takes the hook out (a
|
||||
no-op, as in the mod) when it is not. In ATM10 all 142 name Nature's Spirit, which the
|
||||
pack does not have.
|
||||
The processors that build outside their template are carried too: Repurposed's and
|
||||
Moog's pillar processors become the game's `horizon:pillar` (a column from a placeholder
|
||||
block down to the ground, with the processor list they run on each of its blocks written
|
||||
@@ -517,6 +533,11 @@ Two cases the oracle fills in:
|
||||
the world is air, keep it" rules: around the ocean mineshaft at chunk (-18, -54), seed
|
||||
1234, 650 blocks of air and cave air become prismarine and 614 more template water blocks
|
||||
stay water (1,303 blocks change in its 81 chunks).
|
||||
Its mineshafts' sky view processor (71 lists) is `horizon:mineshaft_sky_view`: a block
|
||||
of the template at or above the world's `OCEAN_FLOOR_WG` height in its column is not
|
||||
placed, so a mineshaft cut by a valley or a ravine is not built in the open, except
|
||||
plain rails over a block that can occlude, which stay (waterlogged where the world holds
|
||||
water). Repurposed's dropped processors go from 253 to 182.
|
||||
Still taken out: Formations' biome replacements, blocks aged or propped up.
|
||||
In the 128×128-chunk square (seed 1234), every spawner of a mod structure now has its mob:
|
||||
in the Overworld 83 Better Dungeons spawners held the template's placeholder pig and 17 of
|
||||
@@ -622,12 +643,10 @@ Two cases the oracle fills in:
|
||||
- When Dungeons Arise, Structory and Towns and Towers use vanilla's types already (T&T's
|
||||
namespaces are carried since the namespaces rule above).
|
||||
- Still lost, written in Java:
|
||||
- Formations' pieced structure (1, its one jigsaw-like type);
|
||||
- Repurposed's mineshafts, mansions and monuments, Better Mineshafts and two Better
|
||||
Dungeons types: Java structure types;
|
||||
- the block processors written in Java
|
||||
(YUNG's banners and decorations, Better Strongholds' legs, Lithostitched's block swaps);
|
||||
- MO Structures (38; its library is only on CurseForge).
|
||||
- **Formations** (Overworld and Nether: 50 structures, 128 pool elements) places one
|
||||
template per start at a height found in the noise columns under its corners and centre: on
|
||||
the surface, on water or lava, or in a cave (on its floor, under its ceiling, buried in
|
||||
@@ -638,6 +657,13 @@ Two cases the oracle fills in:
|
||||
39 Overworld and 672 Nether starts at seed 1234 (51 and 643 at seed 2), and leaves every
|
||||
other start as it was. Its processors (biome replacements, aged blocks, bed colours,
|
||||
plants) are still dropped, so its structures keep their templates' blocks.
|
||||
Its one PiecedStructure, the fountain, starts the same way and then lays out the element's
|
||||
jigsaw children with vanilla's jigsaw: carry writes it as `horizon:formations` with
|
||||
`horizon:pieced` (`max_depth`, `max_distance_from_center`), and the game's assembler takes
|
||||
the placed start piece (`jigsaw::add_children`), the children kept within that distance of
|
||||
the start's centre, one block above the height found. It shares Formations' "uncommon"
|
||||
structure set, so where the set now picks it, it takes the place of a statue or a house:
|
||||
1 fountain at seed 1234 and 3 at seed 2 in chunks -64..63, one with a child piece.
|
||||
|
||||
With every structure PR above, the same square (seed 1234, chunks -64..63) holds 362
|
||||
overworld starts with 22,026 pieces. CTOV has 7, Better Dungeons 72 and Better Ocean
|
||||
@@ -959,6 +985,52 @@ behaviour callbacks and server events (0.3), block entities, menus, capabilities
|
||||
of ATM10 is a campaign. Each step publishes its coverage number; we make no claim beyond what
|
||||
the report shows.
|
||||
|
||||
### Where ATM10's data mods stand (2026-10-22)
|
||||
|
||||
Measured with #1175, #1176, #1177 and #1191 on main, over the 42 data-tier mods carried from
|
||||
ATM10 (the launcher's `--dev-mod` carries), seeds 1234 and 2.
|
||||
|
||||
- **Structures.** The carries define 687 structures in 25 mods. 419 of them start somewhere in
|
||||
chunks -128..127 of the Overworld, the Nether or the End at one of the two seeds, and 264 in
|
||||
chunks -64..63 (249 on 2026-10-19). Most of the rest are biome-bound and rare: no desert lies
|
||||
in either square, and ChoiceTheorem's Overhauled Village (25 of 78) and Towns and Towers (15
|
||||
of 60) have a variant per biome. 27 never start because their type is written in Java:
|
||||
Better Mineshafts 13, Repurposed Structures' mansions 8, monuments 4 and End mineshaft 1, and
|
||||
one Better Dungeons type.
|
||||
- **Processors.** 1,560 carried and 605 dropped (1,347 and 818 on 2026-10-19). The dropped ones
|
||||
by mod: Formations 227 (Overworld 182, Nether 45), Repurposed Structures 182, Better Dungeons
|
||||
69, Better Jungle Temples 50, Better Strongholds 41, Better Desert Temples 14, Villages and
|
||||
Pillages 10, Better Fortresses 5, Better Witch Huts 3, Mo' Structures 3, Moog's Nether
|
||||
Structures 1.
|
||||
- **Content.** 190,550 block states and 12,931 items load. 5 block models are missing (4 of
|
||||
Macaw's Doors' cherry waffle door, Regions Unexplored's blue crystalite): those blocks draw
|
||||
the missing model.
|
||||
- **Load time** (Mac mini M4 Pro, offscreen, render distance 12). The first start builds the
|
||||
content root: about 25 to 35 s, and again after every game update, since the root's stamp
|
||||
includes the executable. Later starts: 6.6 to 8.2 s to "resources loaded", against 1.3 to
|
||||
2.5 s for vanilla.
|
||||
- **Play.** No crash at either seed: spawn, Mo' Structures' pirate ship, castle in the sky,
|
||||
barn house and villager tower, CTOV villages, Formations' fountains (one with its chamber),
|
||||
the Nether.
|
||||
|
||||
What is missing, ranked by how much a player notices:
|
||||
|
||||
1. **Code mods.** 333 of ATM10's 496 mods (machines, magic, mobs, tools, storage): the pack's
|
||||
gameplay. They need behaviour APIs (0.3 and later) and a port per mod.
|
||||
2. **The 12 mods only on CurseForge**, which the carry has not fetched.
|
||||
3. **Plainer structures.** The 605 dropped processors are what makes structures look aged,
|
||||
overgrown or fitted to their biome in Java: Formations' biome swaps and block ageing (its
|
||||
tables are All Rights Reserved code), Repurposed Structures' random and noise replacements
|
||||
and floating-block removal, and YUNG's decorations (Better Dungeons, Jungle Temples,
|
||||
Strongholds).
|
||||
4. **Structures that never generate:** the 27 Java types above. Repurposed Structures' mansions
|
||||
and monuments are about 3,000 lines of LGPL layout code; whether to port them into this MIT
|
||||
repository is a licence question.
|
||||
5. **The first start after installing or updating:** 25 to 35 s. Later starts are about four
|
||||
times vanilla's.
|
||||
6. **Smaller gaps:** Mo' Structures' mixin that keeps lakes out of its structures and its two
|
||||
block-ageing processors, and the 5 missing block models.
|
||||
|
||||
## Open questions for Ludovic
|
||||
|
||||
- Contacting authors of permissively licensed mods to publish ports (none contacted).
|
||||
|
||||
@@ -94,7 +94,8 @@ run at once there, and each must finish within 10 seconds however slowly its byt
|
||||
- **A relay** (only if one is configured) sees the IP addresses of the host and the guests, the short
|
||||
code's first half, when sessions start and end and the size of encrypted frames. It can't see the
|
||||
secret, the key or the game (names, chat, the world). It logs counts every five minutes, nothing else.
|
||||
- **Horizon** sees nothing: there is no Horizon service.
|
||||
- **Horizon** runs no service for it. The game's anonymous "I'm playing" ping says only whether a
|
||||
LAN or friends world is open, never its address, code or name ([telemetry.md](telemetry.md)).
|
||||
|
||||
## Identity
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Horizon collects nothing unless the player says yes. The launcher asks once, with four separate switches that are all off until the player turns one on, and the same switches stay in Settings. The game and the launcher obey the same answer. The code is in `crates/horizon-telemetry`.
|
||||
|
||||
The one exception is the anonymous ["I'm playing" ping](#the-im-playing-ping) behind the launcher's "N playing now": it holds nothing about the player, and the launcher says so next to the switches.
|
||||
|
||||
## Consent
|
||||
|
||||
`telemetry/consent.json` in the game folder (`HORIZON_HOME`, else `horizon` in the platform data folder):
|
||||
@@ -46,10 +48,33 @@ Never sent: account names or UUIDs, world names, server or LAN addresses, chat,
|
||||
- `HORIZON_TELEMETRY_URL` sets the server. There is none by default: until there is, nothing is sent and the outbox stays within its caps.
|
||||
- `SCHEMA` versions the format. New optional fields keep it. A field that changes meaning or goes bumps it, and the server keeps reading the older ones.
|
||||
|
||||
## The "I'm playing" ping
|
||||
|
||||
Ludovic decided on 2026-10-22 to count every player, with or without consent, and to tell them. The launcher's first-run question and Settings → Data collection show it as an "Always on" row next to the switches. Players who answered the question before the ping existed see the same text once on the Play page. The privacy page on playhorizon.dev says the same.
|
||||
|
||||
While the game runs (not a dedicated server, a benchmark or a run that quits by itself), a thread of its own (`crates/horizon-client/src/heartbeat.rs`) sends `POST /v1/live` once a minute. It also sends one at once when a world is opened to or closed from LAN or friends:
|
||||
|
||||
```json
|
||||
{ "session": "9b1c…", "version": "v2026.10.22", "platform": "macos", "lan": false }
|
||||
```
|
||||
|
||||
- `session` is 128 random bits made when the game starts. It is kept only in the game's memory and is not the install ID, so two sessions of one player can't be linked.
|
||||
- `version` is the release, `platform` is `linux`, `macos`, `windows` or `other`, and `lan` says whether a LAN or play-with-friends world is open.
|
||||
- Nothing else: no account, install ID, world, server, settings or hardware. The server checks each field (`live::Ping::check`) and refuses anything more.
|
||||
|
||||
The server keeps sessions in memory only, never on disk, and never with the sender's address. A session counts while its last ping is under 2 minutes old. `GET /v1/live` gives the count, rounded (`live::round`: exact under 10, then to 5, 10 and 100):
|
||||
|
||||
```json
|
||||
{ "playing": 1240, "lan": 85, "versions": { "v2026.10.22": 1100, "other": 140 }, "platforms": { "linux": 300, "macos": 240, "windows": 700 } }
|
||||
```
|
||||
|
||||
The launcher reads it when it opens and every minute after, and shows "N playing now" on the Play page. It shows nothing when the server can't be reached. Like the rest, there is no server by default (`HORIZON_TELEMETRY_URL`): until there is one, the game sends no ping and the launcher shows no count. `HORIZON_NO_HEARTBEAT=1` keeps automated runs out of the count.
|
||||
|
||||
## Server
|
||||
|
||||
- `POST /v1/batch`
|
||||
- `DELETE /v1/installs/<install_id>`: deletes everything sent under that ID.
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads.
|
||||
- `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count.
|
||||
|
||||
`crates/horizon-telemetry-server` is the server. Its [README](../crates/horizon-telemetry-server/README.md) covers storage (about 190 bytes per play session), retention (180 days of raw rows, anonymous daily totals kept), rate limits and hosting.
|
||||
+2
-2
@@ -115,8 +115,8 @@ set `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` to keep launcher updates with the packag
|
||||
Launcher releases are automatic (docs/release-flow.md in the repository): when main changes the
|
||||
launcher, `horizon-prepare` or `horizon-theme`, the `launcher` workflow builds Linux, macOS and
|
||||
Windows and publishes `launcher-vX.Y.Z` (the next patch, or the next minor after a pull request
|
||||
labelled `feature`) with `horizon-launcher-linux-x86_64.tar.gz`, `horizon-launcher-macos-arm64.zip`
|
||||
and `horizon-launcher-windows-x64.zip`, never marked latest. The version is built in from the tag
|
||||
labelled `feature`) with `horizon-launcher-linux-x86_64.tar.gz`, `horizon-launcher-macos-arm64.zip`,
|
||||
`horizon-launcher-macos-x64.zip` (Intel Macs) and `horizon-launcher-windows-x64.zip`, never marked latest. The version is built in from the tag
|
||||
(`HORIZON_LAUNCHER_VERSION`); `Cargo.toml`'s version is only what local builds report.
|
||||
|
||||
## Command line
|
||||
|
||||
+176
-24
@@ -39,31 +39,77 @@ pub struct Release {
|
||||
pub beta: bool,
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
pub const ASSET: Option<&str> = Some("horizon-linux-x86_64.tar.gz");
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
pub const ASSET: Option<&str> = Some("horizon-macos-arm64.zip");
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
pub const ASSET: Option<&str> = Some("horizon-windows-x64.zip");
|
||||
#[cfg(not(any(
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "windows", target_arch = "x86_64")
|
||||
)))]
|
||||
pub const ASSET: Option<&str> = None;
|
||||
/// The release assets of each system Horizon ships for: (`std::env::consts::OS`, `ARCH`, the game,
|
||||
/// the launcher). release.yml and launcher.yml publish exactly these (`platform_assets_are_published`).
|
||||
pub const PLATFORMS: [(&str, &str, &str, &str); 4] = [
|
||||
(
|
||||
"linux",
|
||||
"x86_64",
|
||||
"horizon-linux-x86_64.tar.gz",
|
||||
"horizon-launcher-linux-x86_64.tar.gz",
|
||||
),
|
||||
(
|
||||
"macos",
|
||||
"aarch64",
|
||||
"horizon-macos-arm64.zip",
|
||||
"horizon-launcher-macos-arm64.zip",
|
||||
),
|
||||
// Intel Macs (#1161).
|
||||
(
|
||||
"macos",
|
||||
"x86_64",
|
||||
"horizon-macos-x64.zip",
|
||||
"horizon-launcher-macos-x64.zip",
|
||||
),
|
||||
(
|
||||
"windows",
|
||||
"x86_64",
|
||||
"horizon-windows-x64.zip",
|
||||
"horizon-launcher-windows-x64.zip",
|
||||
),
|
||||
];
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
pub const LAUNCHER_ASSET: Option<&str> = Some("horizon-launcher-linux-x86_64.tar.gz");
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
pub const LAUNCHER_ASSET: Option<&str> = Some("horizon-launcher-macos-arm64.zip");
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
pub const LAUNCHER_ASSET: Option<&str> = Some("horizon-launcher-windows-x64.zip");
|
||||
#[cfg(not(any(
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "windows", target_arch = "x86_64")
|
||||
)))]
|
||||
pub const LAUNCHER_ASSET: Option<&str> = None;
|
||||
/// `a == b`, in a const context.
|
||||
const fn same(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < a.len() {
|
||||
if a[i] != b[i] {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// This system's entry of `PLATFORMS`: the game's asset and the launcher's.
|
||||
const fn platform_assets(os: &str, arch: &str) -> Option<(&'static str, &'static str)> {
|
||||
let mut i = 0;
|
||||
while i < PLATFORMS.len() {
|
||||
let (o, a, game, launcher) = PLATFORMS[i];
|
||||
if same(o, os) && same(a, arch) {
|
||||
return Some((game, launcher));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const OWN_ASSETS: Option<(&str, &str)> =
|
||||
platform_assets(std::env::consts::OS, std::env::consts::ARCH);
|
||||
/// The game build this launcher installs, None where Horizon has none.
|
||||
pub const ASSET: Option<&str> = match OWN_ASSETS {
|
||||
Some((game, _)) => Some(game),
|
||||
None => None,
|
||||
};
|
||||
/// The launcher build this launcher updates itself from.
|
||||
pub const LAUNCHER_ASSET: Option<&str> = match OWN_ASSETS {
|
||||
Some((_, launcher)) => Some(launcher),
|
||||
None => None,
|
||||
};
|
||||
|
||||
const LAUNCHER_BINARY: &str = if cfg!(windows) {
|
||||
"horizon-launcher.exe"
|
||||
@@ -592,6 +638,32 @@ pub fn is_prepared(tag: &str) -> bool {
|
||||
resources(tag).join(RESOURCES_MANIFEST).exists()
|
||||
}
|
||||
|
||||
/// Whether a version's resources hold Minecraft's sounds, which `sounds` copies from the player's own
|
||||
/// install beside the preparation. A version prepared while that copy failed (Mojang's republished 26.3
|
||||
/// asset index, #1076), or one that reused such a version's resources, plays no sound at all (#1035).
|
||||
pub fn has_sounds(tag: &str) -> bool {
|
||||
sounds_in(&resources(tag))
|
||||
}
|
||||
|
||||
fn sounds_in(resources: &Path) -> bool {
|
||||
// What the game's sound registry reads (horizon-client sound_events.rs).
|
||||
resources.join("assets/minecraft/sounds.json").is_file()
|
||||
}
|
||||
|
||||
/// What the Play page says of a version's resources.
|
||||
pub fn resources_summary(prepared: bool, sounds: bool, version: &str) -> String {
|
||||
match (prepared, sounds) {
|
||||
(false, _) => {
|
||||
format!("Not prepared. Horizon needs the files of your own Minecraft {version}.")
|
||||
}
|
||||
(true, true) => format!("Prepared for Minecraft {version}"),
|
||||
(true, false) => format!(
|
||||
"Prepared for Minecraft {version}, without its sounds: the game will be silent. \
|
||||
Prepare again to copy them from your Minecraft install."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the resources `from` prepared are the ones preparing `to` would make. The output
|
||||
/// depends on the Horizon version, not only on Minecraft's: the manifest records the hashes of
|
||||
/// the vanilla exporters it was made with (mods/horizon/src/test/java/.../parity), and the
|
||||
@@ -2040,6 +2112,8 @@ pub enum Msg {
|
||||
PackLine(String),
|
||||
/// A modpack step ended: the pack as it now is.
|
||||
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>),
|
||||
}
|
||||
|
||||
/// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`.
|
||||
@@ -2130,6 +2204,10 @@ pub struct Settings {
|
||||
/// Whether new versions include betas (releases not promoted to stable yet). Off by default.
|
||||
#[serde(default)]
|
||||
pub beta_builds: bool,
|
||||
/// The player has read what the "I'm playing" ping holds (`telemetry::PING`): on the
|
||||
/// first-run question, or on the Play page once if they answered that before it existed.
|
||||
#[serde(default)]
|
||||
pub ping_notice_seen: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -2165,6 +2243,80 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cmp::Ordering::*;
|
||||
|
||||
#[test]
|
||||
fn each_mac_picks_the_build_for_its_processor() {
|
||||
assert_eq!(
|
||||
platform_assets("macos", "aarch64"),
|
||||
Some((
|
||||
"horizon-macos-arm64.zip",
|
||||
"horizon-launcher-macos-arm64.zip"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
platform_assets("macos", "x86_64"),
|
||||
Some(("horizon-macos-x64.zip", "horizon-launcher-macos-x64.zip"))
|
||||
);
|
||||
assert_eq!(
|
||||
platform_assets("windows", "x86_64").map(|(game, _)| game),
|
||||
Some("horizon-windows-x64.zip")
|
||||
);
|
||||
assert_eq!(platform_assets("linux", "aarch64"), None);
|
||||
assert_eq!(platform_assets("macos", "x86"), None);
|
||||
assert_eq!(
|
||||
ASSET,
|
||||
platform_assets(std::env::consts::OS, std::env::consts::ARCH).map(|(game, _)| game)
|
||||
);
|
||||
}
|
||||
|
||||
/// Every asset a launcher looks for is one the release workflows build, sign and publish.
|
||||
#[test]
|
||||
fn platform_assets_are_published() {
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let read = |path: &str| fs::read_to_string(repo.join(path)).unwrap();
|
||||
let release = read(".github/workflows/release.yml");
|
||||
let launcher = read(".github/workflows/launcher.yml");
|
||||
for (os, arch, game, launcher_asset) in PLATFORMS {
|
||||
assert!(
|
||||
release.contains(&format!("asset: {game}")),
|
||||
"release.yml builds no {game}"
|
||||
);
|
||||
// Signed (SHA256SUMS), then published.
|
||||
assert_eq!(
|
||||
release.matches(&format!("dist/{game}")).count(),
|
||||
2,
|
||||
"release.yml must sign and publish {game} ({os} {arch})"
|
||||
);
|
||||
assert!(
|
||||
launcher.contains(&format!("asset: {launcher_asset}")),
|
||||
"launcher.yml builds no {launcher_asset}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources_without_sounds_are_reported() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-no-sounds-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("assets/minecraft/textures")).unwrap();
|
||||
fs::write(dir.join(RESOURCES_MANIFEST), "{}").unwrap();
|
||||
assert!(!sounds_in(&dir));
|
||||
fs::write(dir.join("assets/minecraft/sounds.json"), "{}").unwrap();
|
||||
assert!(sounds_in(&dir));
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resources_summary(true, true, "26.3"),
|
||||
"Prepared for Minecraft 26.3"
|
||||
);
|
||||
let silent = resources_summary(true, false, "26.3");
|
||||
assert!(silent.contains("without its sounds") && silent.contains("Prepare again"));
|
||||
// Not prepared says only that, sounds or not.
|
||||
assert_eq!(
|
||||
resources_summary(false, false, "26.3"),
|
||||
resources_summary(false, true, "26.3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_game_plays_under_its_own_audio_stream_name() {
|
||||
if std::env::var_os("PIPEWIRE_PROPS").is_some() {
|
||||
|
||||
+4
-2
@@ -422,10 +422,12 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let state = if backend::is_prepared(tag) {
|
||||
let state = if !backend::is_prepared(tag) {
|
||||
"not prepared"
|
||||
} else if backend::has_sounds(tag) {
|
||||
"prepared"
|
||||
} else {
|
||||
"not prepared"
|
||||
"prepared, no sounds (prepare again)"
|
||||
};
|
||||
println!("{mark} {tag} {state}");
|
||||
}
|
||||
|
||||
@@ -45,6 +45,41 @@ fn kind_id(prefix: &str, kind: DataKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a switch would be: the ping has none.
|
||||
fn always_on(t: &Theme) -> Div {
|
||||
caps("Always on").flex_none().text_color(t.muted)
|
||||
}
|
||||
|
||||
/// The "I'm playing" ping on the first-run question, laid out as the switches' rows.
|
||||
fn ping_row(t: &Theme) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap(px(16.))
|
||||
.py(px(10.))
|
||||
.border_b_1()
|
||||
.border_color(t.rule)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap(px(2.))
|
||||
.child(
|
||||
text(theme::UI)
|
||||
.text_color(t.ink)
|
||||
.child(telemetry::PING_NAME),
|
||||
)
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.child(telemetry::PING),
|
||||
),
|
||||
)
|
||||
.child(always_on(t))
|
||||
}
|
||||
|
||||
/// The exact JSON, in mono, scrolling inside a plate.
|
||||
fn json_view(view: &(String, String), t: &Theme, scroll: &Scrollbar) -> Div {
|
||||
div()
|
||||
@@ -87,6 +122,11 @@ impl Launcher {
|
||||
Ok(consent) => self.collection.consent = consent,
|
||||
Err(e) => self.collection.note = Some(format!("Could not save your choice: {e}")),
|
||||
}
|
||||
// The question and Settings say what the "I'm playing" ping holds, beside the switches.
|
||||
if !self.settings.ping_notice_seen {
|
||||
self.settings.ping_notice_seen = true;
|
||||
self.settings.save();
|
||||
}
|
||||
self.collection.view = None;
|
||||
}
|
||||
|
||||
@@ -148,9 +188,17 @@ impl Launcher {
|
||||
.child("Help make Horizon faster and steadier?"),
|
||||
)
|
||||
.child(text(theme::UI).text_color(t.muted).child(
|
||||
"Horizon can send a few things to its developers. Everything is off until you turn it on, and Settings can change it any time.",
|
||||
"Horizon can send a few things to its developers. Each switch is off until you turn it on, and Settings can change it any time.",
|
||||
))
|
||||
.child(div().flex().flex_col().border_t_1().border_color(t.rule).children(rows))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.border_t_1()
|
||||
.border_color(t.rule)
|
||||
.children(rows)
|
||||
.child(ping_row(&t)),
|
||||
)
|
||||
.child(text(theme::CAPTION).text_color(t.muted).child(telemetry::NEVER))
|
||||
.child(
|
||||
div()
|
||||
@@ -244,6 +292,13 @@ impl Launcher {
|
||||
&t,
|
||||
));
|
||||
}
|
||||
list = list.child(index_row(
|
||||
telemetry::SWITCHES.len() + 1,
|
||||
telemetry::PING_NAME,
|
||||
Some(div().child(telemetry::PING).into_any_element()),
|
||||
Some(always_on(&t).into_any_element()),
|
||||
&t,
|
||||
));
|
||||
let waiting = horizon_telemetry::outbox::pending(&telemetry::home()).len();
|
||||
let id = match consent.install_id.as_str() {
|
||||
"" => "No install ID: nothing has been collected".to_string(),
|
||||
@@ -299,7 +354,7 @@ impl Launcher {
|
||||
})),
|
||||
);
|
||||
list = list.child(index_row(
|
||||
telemetry::SWITCHES.len() + 1,
|
||||
telemetry::SWITCHES.len() + 2,
|
||||
"Your data",
|
||||
Some(
|
||||
div()
|
||||
@@ -316,7 +371,7 @@ impl Launcher {
|
||||
.gap(px(16.))
|
||||
.child(text(theme::HEADING).text_color(t.ink).child("Data collection"))
|
||||
.child(text(theme::UI).max_w(px(600.)).text_color(t.muted).child(
|
||||
"Off unless you turn it on. What each switch sends is below it; the privacy page on playhorizon.dev says how long it is kept.",
|
||||
"Each switch is off unless you turn it on. What it sends is below it; the privacy page on playhorizon.dev says how long it is kept.",
|
||||
))
|
||||
.child(list)
|
||||
.child(text(theme::CAPTION).text_color(t.muted).child(telemetry::NEVER))
|
||||
|
||||
+87
-5
@@ -163,6 +163,8 @@ struct Launcher {
|
||||
args_focus: FocusHandle,
|
||||
/// Opt-in data collection: the player's answer and Settings' section (`collection`).
|
||||
collection: collection::Collection,
|
||||
/// How many play Horizon now, while the server answers ("N playing now" on the Play page).
|
||||
playing: Option<u64>,
|
||||
tx: Sender<Msg>,
|
||||
rx: Receiver<Msg>,
|
||||
_appearance: Subscription,
|
||||
@@ -280,6 +282,7 @@ impl Launcher {
|
||||
friend_focus: cx.focus_handle(),
|
||||
joining: None,
|
||||
collection: collection::Collection::load(),
|
||||
playing: None,
|
||||
tx,
|
||||
rx,
|
||||
_appearance: appearance,
|
||||
@@ -317,6 +320,7 @@ impl Launcher {
|
||||
this.fetch_links();
|
||||
// What the game and the launcher kept, if the player allows it and there is a server.
|
||||
telemetry::send_in_background();
|
||||
telemetry::watch_playing(this.tx.clone());
|
||||
this
|
||||
}
|
||||
|
||||
@@ -426,6 +430,7 @@ impl Launcher {
|
||||
// The page reads is_prepared when drawn; `changed` redraws it.
|
||||
Msg::Reused(_) => self.installed = backend::installed(),
|
||||
Msg::Keychain(persistent) => self.keychain = persistent,
|
||||
Msg::Playing(playing) => self.playing = playing,
|
||||
Msg::Session(session) => {
|
||||
if let Some(tag) = self.starting_client.take() {
|
||||
let mut cmd = backend::client_command(&tag);
|
||||
@@ -1146,6 +1151,19 @@ fn centered(text_: &str, t: &Theme) -> Div {
|
||||
.child(text(theme::UI).text_color(t.muted).child(text_.to_string()))
|
||||
}
|
||||
|
||||
/// `1234` as `1,234`.
|
||||
fn thousands(n: u64) -> String {
|
||||
let digits = n.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in digits.chars().enumerate() {
|
||||
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A mono line in upper case: the status above a title, the footer's links.
|
||||
fn caps(s: impl AsRef<str>) -> Div {
|
||||
text(theme::MICRO).child(s.as_ref().to_uppercase())
|
||||
@@ -1388,6 +1406,8 @@ impl Launcher {
|
||||
));
|
||||
};
|
||||
let prepared = backend::is_prepared(&tag);
|
||||
// Prepared without sounds (#1035): Prepare again is the fix, so it stands out.
|
||||
let silent = prepared && !backend::has_sounds(&tag);
|
||||
let version = backend::game_version(&tag);
|
||||
let hero = self.hero(&tag, &version, prepared, cx);
|
||||
let running = self.client.is_some() || self.starting_client.is_some();
|
||||
@@ -1429,15 +1449,13 @@ impl Launcher {
|
||||
|
||||
let resources_value = if self.preparing {
|
||||
"Preparing…".to_string()
|
||||
} else if prepared {
|
||||
format!("Prepared for Minecraft {version}")
|
||||
} else {
|
||||
format!("Not prepared. Horizon needs the files of your own Minecraft {version}.")
|
||||
backend::resources_summary(prepared, !silent, &version)
|
||||
};
|
||||
let mut prepare = button(
|
||||
"prepare",
|
||||
if prepared { "Prepare again" } else { "Prepare" },
|
||||
if prepared {
|
||||
if prepared && !silent {
|
||||
Kind::Ghost
|
||||
} else {
|
||||
Kind::Secondary
|
||||
@@ -1688,9 +1706,11 @@ impl Launcher {
|
||||
_ => None,
|
||||
};
|
||||
let title = "Horizon";
|
||||
// Never squeezed by the rows below: the ping notice can make it tall.
|
||||
let mut hero =
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.flex_col()
|
||||
.gap(px(14.))
|
||||
.pt(px(24.))
|
||||
@@ -1704,7 +1724,18 @@ impl Launcher {
|
||||
.child(
|
||||
caps(if prepared { "Ready" } else { "Needs preparing" })
|
||||
.text_color(if prepared { t.success } else { t.muted }),
|
||||
),
|
||||
)
|
||||
.children(self.playing.map(|n| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(10.))
|
||||
.child(div().size(px(4.)).rounded_full().bg(t.rule))
|
||||
.child(
|
||||
caps(format!("{} playing now", thousands(n)))
|
||||
.text_color(t.muted),
|
||||
)
|
||||
})),
|
||||
)
|
||||
.child(text(theme::DISPLAY).text_color(t.ink).child(title))
|
||||
.child(text(theme::BODY).max_w(px(560.)).text_color(t.muted).child(
|
||||
@@ -1747,6 +1778,45 @@ impl Launcher {
|
||||
),
|
||||
});
|
||||
}
|
||||
// Players who answered the first-run question before the ping existed read about it
|
||||
// once here.
|
||||
if !self.settings.ping_notice_seen && !self.collection.consent.needs_asking() {
|
||||
hero = hero.child(
|
||||
plate(&t)
|
||||
.flex()
|
||||
.flex_none()
|
||||
.flex_col()
|
||||
.gap(px(8.))
|
||||
.p(px(16.))
|
||||
.w_full()
|
||||
.max_w(px(640.))
|
||||
.child(
|
||||
text(theme::UI)
|
||||
.text_color(t.ink)
|
||||
.child(telemetry::PING_NAME),
|
||||
)
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.text_color(t.muted)
|
||||
.child(telemetry::PING),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("ping-notice-ok")
|
||||
.text_size(px(theme::UI.px))
|
||||
.text_color(t.accent)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.opacity(0.8))
|
||||
.child("Got it")
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.settings.ping_notice_seen = true;
|
||||
this.settings.save();
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
hero
|
||||
}
|
||||
|
||||
@@ -2918,3 +2988,15 @@ fn main() {
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn counts_get_thousands_separators() {
|
||||
let shown: Vec<String> = [0, 7, 999, 1000, 12_300, 1_234_500]
|
||||
.into_iter()
|
||||
.map(super::thousands)
|
||||
.collect();
|
||||
assert_eq!(shown, ["0", "7", "999", "1,000", "12,300", "1,234,500"]);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -85,7 +85,7 @@ impl Session {
|
||||
"list_versions" => {
|
||||
let installed = backend::installed();
|
||||
let mut out = json!({
|
||||
"installed": installed.iter().map(|t| json!({ "tag": t, "prepared": backend::is_prepared(t) })).collect::<Vec<_>>(),
|
||||
"installed": installed.iter().map(|t| json!({ "tag": t, "prepared": backend::is_prepared(t), "sounds": backend::has_sounds(t) })).collect::<Vec<_>>(),
|
||||
"selected": backend::resolve_version(None).ok(),
|
||||
});
|
||||
if !args
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Opt-in data collection from the launcher (`horizon_telemetry`): the player's answer, what
|
||||
//! would be sent, deleting it, bug reports and benchmark uploads, and sending the outbox when
|
||||
//! the launcher starts and closes. The window, the CLI and the MCP server all go through here.
|
||||
//! Also the live count's side here: what the "I'm playing" ping holds, in words, and reading
|
||||
//! how many play now (`horizon_telemetry::live`).
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use horizon_telemetry::payload::{Bug, Crash, Perf, System, Timings};
|
||||
@@ -37,6 +39,33 @@ pub const SWITCHES: [(Kind, &str, &str); 4] = [
|
||||
/// Never sent, whatever the switches say.
|
||||
pub const NEVER: &str = "Never sent: your account name or UUID, world names, server addresses, chat, or your IP address. Each report is dated to the day, under a random install ID you can delete.";
|
||||
|
||||
/// The anonymous "I'm playing" ping the game sends whatever the switches say (Ludovic's
|
||||
/// decision, 2026-10-22: count everyone, and tell them). Shown on the first-run question, in
|
||||
/// Settings, and once on the Play page to players who answered before it existed.
|
||||
pub const PING_NAME: &str = "\"I'm playing\" ping";
|
||||
pub const PING: &str = "While you play, the game sends an anonymous \"I'm playing\" ping once a minute, so the launcher can show how many people play Horizon right now. It holds: a random number made when the game starts and forgotten when it closes, the Horizon version, your OS (Linux, macOS or Windows), and whether you have a world open to LAN or friends. It doesn't hold: your account, your install ID, world or server names, or anything you do in the game, and the server doesn't keep your IP address. It is sent whatever the switches say.";
|
||||
|
||||
/// How many play Horizon now (rounded), if the server says.
|
||||
pub fn playing_now() -> Option<u64> {
|
||||
let endpoint = horizon_telemetry::endpoint()?;
|
||||
horizon_telemetry::live::fetch(&endpoint)
|
||||
.ok()
|
||||
.map(|count| count.playing)
|
||||
}
|
||||
|
||||
/// Sends [`playing_now`] to the window as `Msg::Playing` now and every minute, on its own
|
||||
/// thread. Nothing at all when there is no server.
|
||||
pub fn watch_playing(tx: std::sync::mpsc::Sender<backend::Msg>) {
|
||||
if horizon_telemetry::endpoint().is_none() {
|
||||
return;
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
while tx.send(backend::Msg::Playing(playing_now())).is_ok() {
|
||||
std::thread::sleep(horizon_telemetry::live::INTERVAL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn home() -> PathBuf {
|
||||
backend::game_home()
|
||||
}
|
||||
@@ -249,6 +278,7 @@ pub fn status_json() -> serde_json::Value {
|
||||
"install_id": consent.install_id,
|
||||
"waiting": outbox::pending(&home()).len(),
|
||||
"server": horizon_telemetry::endpoint(),
|
||||
"ping": PING,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ import net.minecraft.network.protocol.game.ClientboundChunkBatchFinishedPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundDisguisedChatPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundLoginPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
|
||||
@@ -124,6 +125,8 @@ import net.minecraft.world.entity.player.ProfilePublicKey;
|
||||
*/
|
||||
public final class SecureChatClient {
|
||||
private static final List<String> LINES = new ArrayList<>();
|
||||
/** A command any player may run, whose reply only its sender gets (RandomCommand: value). */
|
||||
private static final String BARRIER = "random value 1..2";
|
||||
private static PrivateKey services;
|
||||
private static String sessionUrl;
|
||||
|
||||
@@ -203,7 +206,7 @@ public final class SecureChatClient {
|
||||
settle(all, "alice's chain is broken");
|
||||
bob.send(new ServerboundChatAckPacket(50));
|
||||
settle(all, "bob acknowledges too much");
|
||||
for (Client c : all) c.close();
|
||||
leave(all);
|
||||
}
|
||||
|
||||
/** Online mode with enforce-secure-profile: unsigned chat is refused. */
|
||||
@@ -223,13 +226,35 @@ public final class SecureChatClient {
|
||||
// A key the services signed for another player.
|
||||
Client mallory = Client.join(host, port, "Mallory", true, UUID.nameUUIDFromBytes("someone else".getBytes()));
|
||||
settle(List.of(alice, carol, mallory), "mallory's borrowed key");
|
||||
for (Client c : all) c.close();
|
||||
leave(all);
|
||||
mallory.close();
|
||||
}
|
||||
|
||||
/** Lets the server answer, then records what each player received, in player order. */
|
||||
/**
|
||||
* The players still connected leave one at a time, each once the next was
|
||||
* told the one before left (PlayerList.remove broadcasts the removal after
|
||||
* the "lost connection" line): the server logs the departures in this
|
||||
* order, not in the order its network threads notice the closed sockets.
|
||||
*/
|
||||
private static void leave(List<Client> all) throws Exception {
|
||||
List<Client> clients = all.stream().filter(c -> !c.closed).toList();
|
||||
for (int i = 0; i < clients.size(); i++) {
|
||||
clients.get(i).close();
|
||||
if (i + 1 < clients.size()) clients.get(i + 1).awaitRemoved(clients.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the server answer, then records what each player received, in player order. However slow the
|
||||
* server, a step ends once it handled everything sent so far: each player still connected runs a command
|
||||
* whose reply comes after it, twice (a chat's broadcast is a task the chat's own task queues), and has then
|
||||
* read whatever the server sent it before.
|
||||
*/
|
||||
private static void settle(List<Client> clients, String step) throws Exception {
|
||||
Thread.sleep(1500);
|
||||
for (int round = 0; round < 2; round++) {
|
||||
for (Client c : clients) c.barrier();
|
||||
}
|
||||
LINES.add("step " + step);
|
||||
for (Client c : clients) c.drain();
|
||||
}
|
||||
@@ -249,6 +274,10 @@ public final class SecureChatClient {
|
||||
ProtocolInfo<?> inbound;
|
||||
ProtocolInfo<?> outbound;
|
||||
final BlockingQueue<Packet<?>> queue = new LinkedBlockingQueue<>();
|
||||
/** Replies to BARRIER, kept out of the queue. */
|
||||
final java.util.concurrent.Semaphore barriers = new java.util.concurrent.Semaphore(0);
|
||||
/** The server ended the connection (its reader stopped). */
|
||||
volatile boolean ended;
|
||||
volatile boolean closed;
|
||||
LocalChatSession session;
|
||||
SignedMessageChain.Encoder encoder = SignedMessageChain.Encoder.UNSIGNED;
|
||||
@@ -359,13 +388,21 @@ public final class SecureChatClient {
|
||||
try {
|
||||
while (!closed) {
|
||||
Packet<?> p = read();
|
||||
if (p instanceof ClientboundSystemChatPacket s
|
||||
&& s.content().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t
|
||||
&& t.getKey().equals("commands.random.sample.success")) {
|
||||
barriers.release();
|
||||
continue;
|
||||
}
|
||||
queue.put(p);
|
||||
// Nothing follows a disconnect but the closed socket.
|
||||
if (p instanceof ClientboundDisconnectPacket) return;
|
||||
if (p instanceof ClientboundDisconnectPacket) break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (!closed) queue.add(new ClientboundDisconnectPacket(Component.literal("connection lost: " + e)));
|
||||
}
|
||||
ended = true;
|
||||
barriers.release();
|
||||
}, name + " reader");
|
||||
reader.setDaemon(true);
|
||||
reader.start();
|
||||
@@ -437,6 +474,21 @@ public final class SecureChatClient {
|
||||
send(new ServerboundChatCommandPacket(command));
|
||||
}
|
||||
|
||||
/** Runs BARRIER and waits for its reply (30 s at most), which isn't recorded. */
|
||||
void barrier() throws Exception {
|
||||
if (closed || ended) return;
|
||||
barriers.drainPermits();
|
||||
try {
|
||||
send(new ServerboundChatCommandPacket(BARRIER));
|
||||
} catch (java.io.IOException e) {
|
||||
if (ended) return;
|
||||
throw e;
|
||||
}
|
||||
if (!barriers.tryAcquire(30, TimeUnit.SECONDS) && !ended) {
|
||||
LINES.add("error " + name + " got no reply to /" + BARRIER);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles and records what arrived. */
|
||||
void drain() throws Exception {
|
||||
Packet<?> p;
|
||||
@@ -489,6 +541,18 @@ public final class SecureChatClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits (10 s at most) until the server removes `gone` from this player's list; records nothing else. */
|
||||
void awaitRemoved(Client gone) throws Exception {
|
||||
long until = System.nanoTime() + 10_000_000_000L;
|
||||
while (System.nanoTime() < until) {
|
||||
Packet<?> p = queue.poll(50, TimeUnit.MILLISECONDS);
|
||||
if (p instanceof ClientboundPlayerInfoRemovePacket r && r.profileIds().contains(gone.id)) return;
|
||||
if (p instanceof ClientboundKeepAlivePacket k) send(new ServerboundKeepAlivePacket(k.getId()));
|
||||
else if (p instanceof ClientboundPingPacket ping) send(new ServerboundPongPacket(ping.getId()));
|
||||
}
|
||||
LINES.add("error " + name + " was never told that " + gone.name + " left");
|
||||
}
|
||||
|
||||
/** ClientPacketListener.handlePlayerChat, then markMessageAsProcessed. */
|
||||
void playerChat(ClientboundPlayerChatPacket p) {
|
||||
int expected = nextChatIndex++;
|
||||
|
||||
@@ -39,6 +39,8 @@ public final class VanillaMenuOracle {
|
||||
static final List<String> equips = new ArrayList<>();
|
||||
/** The block entity behind a "crafter_be" menu (slot state packets go to it). */
|
||||
static net.minecraft.world.level.block.entity.CrafterBlockEntity crafter;
|
||||
/** The block entity behind a "lectern" menu. */
|
||||
static net.minecraft.world.level.block.entity.LecternBlockEntity lectern;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static <T> T make(Class<T> type) {
|
||||
@@ -87,8 +89,22 @@ public final class VanillaMenuOracle {
|
||||
// BlockEntity.setChanged of the "*_be" furnaces: no chunks here.
|
||||
@Override public void blockEntityChanged(BlockPos pos) { }
|
||||
@Override public void updateNeighbourForOutputSignal(BlockPos pos, net.minecraft.world.level.block.Block block) { }
|
||||
// The case's maps (MapItem.getSavedData) and the ones the cartography table makes.
|
||||
@Override public net.minecraft.world.level.saveddata.maps.MapItemSavedData getMapData(net.minecraft.world.level.saveddata.maps.MapId id) { return maps.get(id.id()); }
|
||||
@Override public net.minecraft.world.level.saveddata.maps.MapId getFreeMapId() { return new net.minecraft.world.level.saveddata.maps.MapId(nextMap++); }
|
||||
@Override public void setMapData(net.minecraft.world.level.saveddata.maps.MapId id, net.minecraft.world.level.saveddata.maps.MapItemSavedData data) { maps.put(id.id(), data); }
|
||||
@Override public long getGameTime() { return 0L; }
|
||||
// LecternBlock.signalPageChange and resetBookState: no chunks or ticks here.
|
||||
@Override public boolean setBlock(BlockPos pos, net.minecraft.world.level.block.state.BlockState state, int flags, int recursion) { return true; }
|
||||
@Override public void scheduleTick(BlockPos pos, net.minecraft.world.level.block.Block block, int delay) { }
|
||||
@Override public void updateNeighborsAt(BlockPos pos, net.minecraft.world.level.block.Block block, net.minecraft.world.level.redstone.Orientation o) { }
|
||||
@Override public void playSound(net.minecraft.world.entity.Entity e, BlockPos pos, net.minecraft.sounds.SoundEvent s, net.minecraft.sounds.SoundSource c, float v, float pitch) { }
|
||||
}
|
||||
|
||||
/** The maps of the case's level, by id, and the next free id. */
|
||||
static final Map<Integer, net.minecraft.world.level.saveddata.maps.MapItemSavedData> maps = new HashMap<>();
|
||||
static int nextMap;
|
||||
|
||||
static final class FakePlayer extends ServerPlayer {
|
||||
World world;
|
||||
boolean creative;
|
||||
@@ -178,10 +194,42 @@ public final class VanillaMenuOracle {
|
||||
}
|
||||
}
|
||||
case "loom" -> new LoomMenu(0, inv);
|
||||
case "stonecutter" -> new StonecutterMenu(0, inv);
|
||||
case "grindstone" -> new GrindstoneMenu(0, inv);
|
||||
case "anvil" -> new AnvilMenu(0, inv);
|
||||
case "smithing" -> new SmithingMenu(0, inv);
|
||||
case "enchanting" -> new EnchantmentMenu(0, inv);
|
||||
case "beacon" -> new BeaconMenu(0, inv);
|
||||
// Over the level (its maps), as the server's menu is.
|
||||
case "cartography" -> new CartographyTableMenu(0, inv, access);
|
||||
case "lectern" -> {
|
||||
// The server's menu: the block entity's bookAccess and dataAccess.
|
||||
lectern = new net.minecraft.world.level.block.entity.LecternBlockEntity(BlockPos.ZERO,
|
||||
net.minecraft.world.level.block.Blocks.LECTERN.defaultBlockState());
|
||||
lectern.setLevel(player.world);
|
||||
try {
|
||||
var type = net.minecraft.world.level.block.entity.LecternBlockEntity.class;
|
||||
Field book = type.getDeclaredField("bookAccess");
|
||||
Field data = type.getDeclaredField("dataAccess");
|
||||
book.setAccessible(true);
|
||||
data.setAccessible(true);
|
||||
yield new LecternMenu(0, (Container) book.get(lectern), (ContainerData) data.get(lectern));
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
default -> throw new IllegalArgumentException(kind);
|
||||
};
|
||||
}
|
||||
|
||||
/** A SET_BEACON power: a mob effect's name, or null for none. */
|
||||
static Optional<net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>> effect(JsonElement name) {
|
||||
if (name.isJsonNull()) return Optional.empty();
|
||||
return net.minecraft.core.registries.BuiltInRegistries.MOB_EFFECT
|
||||
.get(net.minecraft.resources.Identifier.parse(name.getAsString()))
|
||||
.map(h -> (net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>) h);
|
||||
}
|
||||
|
||||
static String state(AbstractContainerMenu menu, Inventory inv) throws Exception {
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (int i = 0; i < menu.slots.size(); i++) {
|
||||
@@ -199,6 +247,12 @@ public final class VanillaMenuOracle {
|
||||
|
||||
/** {@link #state} and what was equipped: the menu_click harness's line (place_recipe prints {@code state}). */
|
||||
static String clickState(AbstractContainerMenu menu, Inventory inv) throws Exception {
|
||||
if (menu instanceof LecternMenu) {
|
||||
// No inventory slots: the inventory Take Book fills.
|
||||
StringBuilder items = new StringBuilder();
|
||||
for (int i = 0; i < 36; i++) items.append(' ').append(ParityData.canon(inv.getItem(i)));
|
||||
return state(menu, inv) + " | " + String.join(";", equips) + " | inv" + items;
|
||||
}
|
||||
return state(menu, inv) + " | " + String.join(";", equips);
|
||||
}
|
||||
|
||||
@@ -225,6 +279,25 @@ public final class VanillaMenuOracle {
|
||||
player.advancements = make(net.minecraft.server.PlayerAdvancements.class);
|
||||
set(player.advancements, net.minecraft.server.PlayerAdvancements.class, "activeTriggers", new java.util.IdentityHashMap<>());
|
||||
set(player, ServerPlayer.class, "connection", make(Connection.class));
|
||||
// AnvilMenu.onTake filters a new name; the anvil's result needs the levels.
|
||||
set(player, ServerPlayer.class, "textFilter", net.minecraft.server.network.TextFilter.DUMMY);
|
||||
// EnchantmentMenu.clickMenuButton names the player on an invalid button.
|
||||
set(player, Player.class, "gameProfile", new com.mojang.authlib.GameProfile(new java.util.UUID(0, 1), "Oracle"));
|
||||
player.experienceLevel = f.has("levels") ? f.get("levels").getAsInt() : 0;
|
||||
maps.clear();
|
||||
nextMap = 1000;
|
||||
lectern = null;
|
||||
// LecternMenu's Take Book asks Player.mayBuild.
|
||||
set(player, Player.class, "abilities", new net.minecraft.world.entity.player.Abilities());
|
||||
if (f.has("maps")) {
|
||||
// [id, scale, locked] each.
|
||||
for (var m : f.getAsJsonArray("maps")) {
|
||||
var a = m.getAsJsonArray();
|
||||
var data = net.minecraft.world.level.saveddata.maps.MapItemSavedData.createFresh(0, 0,
|
||||
(byte) a.get(1).getAsInt(), false, false, net.minecraft.world.level.Level.OVERWORLD);
|
||||
maps.put(a.get(0).getAsInt(), a.get(2).getAsBoolean() ? data.locked() : data);
|
||||
}
|
||||
}
|
||||
var inv = new Inventory(player, new EntityEquipment());
|
||||
player.inv = inv;
|
||||
AbstractContainerMenu menu = menu(f.get("menu").getAsString(), player, inv);
|
||||
@@ -233,6 +306,19 @@ public final class VanillaMenuOracle {
|
||||
var slotsJson = f.getAsJsonArray("slots");
|
||||
for (int i = 0; i < menu.slots.size(); i++) menu.getSlot(i).set(ParityData.stack(slotsJson.get(i).getAsString()));
|
||||
menu.setCarried(ParityData.stack(f.get("carried").getAsString()));
|
||||
if (lectern != null && menu instanceof LecternMenu) {
|
||||
// LecternBlockEntity.setBook without resolving the book: its slot can't set it.
|
||||
var type = net.minecraft.world.level.block.entity.LecternBlockEntity.class;
|
||||
ItemStack book = ParityData.stack(slotsJson.get(0).getAsString());
|
||||
set(lectern, type, "book", book);
|
||||
var count = type.getDeclaredMethod("getPageCount", ItemStack.class);
|
||||
count.setAccessible(true);
|
||||
set(lectern, type, "pageCount", count.invoke(null, book));
|
||||
}
|
||||
if (f.has("inventory")) {
|
||||
var items = f.getAsJsonArray("inventory");
|
||||
for (int i = 0; i < items.size(); i++) inv.setItem(i, ParityData.stack(items.get(i).getAsString()));
|
||||
}
|
||||
// The slot listener of the block crafter's menu refreshes the result.
|
||||
if (f.get("menu").getAsString().equals("crafter_be")) menu.broadcastChanges();
|
||||
var dataJson = f.getAsJsonArray("data");
|
||||
@@ -251,6 +337,18 @@ public final class VanillaMenuOracle {
|
||||
out.println(clickState(menu, inv));
|
||||
continue;
|
||||
}
|
||||
if (kind.equals("RENAME")) {
|
||||
// ServerGamePacketListenerImpl.handleRenameItem: AnvilMenu.setItemName
|
||||
if (menu instanceof AnvilMenu anvil) anvil.setItemName(op.get(3).getAsString());
|
||||
out.println(clickState(menu, inv));
|
||||
continue;
|
||||
}
|
||||
if (kind.equals("SET_BEACON")) {
|
||||
// ServerGamePacketListenerImpl.handleSetBeaconPacket: BeaconMenu.updateEffects
|
||||
if (menu instanceof BeaconMenu beacon) beacon.updateEffects(effect(op.get(3)), effect(op.get(4)));
|
||||
out.println(clickState(menu, inv));
|
||||
continue;
|
||||
}
|
||||
if (kind.equals("SLOT_STATE")) {
|
||||
// handleContainerSlotStateChanged: CrafterBlockEntity.setSlotState
|
||||
if (slot >= 0 && slot < 9) crafter.setSlotState(slot, button == 1);
|
||||
|
||||
@@ -411,6 +411,12 @@ public final class VanillaProtocolClient {
|
||||
/** The entity mode's step, and how long it records the entities for (ms). */
|
||||
private static final int ENTITIES = 1000;
|
||||
private static final long ENTITY_WAIT = 20_000;
|
||||
/**
|
||||
* How long a step waits at most for what it waits on (a villager to trade with, the ingot, a thrown
|
||||
* snowball, the pearl's teleport and hurt): a busy machine can hold a server's ticks back for seconds,
|
||||
* so the steps wait for those, not for a fixed time.
|
||||
*/
|
||||
private static final long PATIENCE = 30_000;
|
||||
|
||||
static int moveEntityId(net.minecraft.network.protocol.game.ClientboundMoveEntityPacket p) {
|
||||
return (int) field(p, net.minecraft.network.protocol.game.ClientboundMoveEntityPacket.class, "entityId");
|
||||
@@ -464,6 +470,9 @@ public final class VanillaProtocolClient {
|
||||
int furnaceState = 0;
|
||||
// Snowballs the server spawned since the last right click.
|
||||
int snowballs = 0;
|
||||
// When the first of them came, and when the furnace's ingot did.
|
||||
long snowballAt = 0;
|
||||
long smeltedAt = 0;
|
||||
// The player's own id, and what the ender pearl it throws does to it.
|
||||
int playerId = -1;
|
||||
boolean pearl = false;
|
||||
@@ -550,6 +559,7 @@ public final class VanillaProtocolClient {
|
||||
&& furnaceWindow > 0 && p.getContainerId() == furnaceWindow) {
|
||||
furnaceState = p.getStateId();
|
||||
record("play-furnace", "slot " + p.getSlot() + " " + p.getItem());
|
||||
if (p.getSlot() == 2 && !p.getItem().isEmpty() && smeltedAt == 0) smeltedAt = now;
|
||||
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundContainerSetDataPacket p
|
||||
&& furnaceWindow > 0 && p.getContainerId() == furnaceWindow && (p.getId() == 1 || p.getId() == 3)) {
|
||||
// Lit total and cooking total; the time left and the cooking timer follow the ticks.
|
||||
@@ -580,6 +590,7 @@ public final class VanillaProtocolClient {
|
||||
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket p
|
||||
&& p.getType() == net.minecraft.world.entity.EntityTypes.SNOWBALL) {
|
||||
snowballs++;
|
||||
if (snowballAt == 0) snowballAt = now;
|
||||
// Projectile.getAddEntityPacket: its owner's id.
|
||||
record("play-use", "snowball owner is the player: " + (p.getData() == playerId));
|
||||
} else if (packet instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket p
|
||||
@@ -666,8 +677,8 @@ public final class VanillaProtocolClient {
|
||||
send(new ServerboundChatCommandPacket(REPLIES[step - 2]));
|
||||
step++;
|
||||
stepAt = now;
|
||||
} else if (step == 2 + REPLIES.length && now - stepAt > 1500) {
|
||||
// Right-click the villager: the trading screen and its offers.
|
||||
} else if (step == 2 + REPLIES.length && now - stepAt > 1500 && (villager >= 0 || now - stepAt > PATIENCE)) {
|
||||
// Right-click the villager (once the server sent it): the trading screen and its offers.
|
||||
record("play-trade", "villager " + (villager >= 0));
|
||||
if (villager >= 0) {
|
||||
send(new net.minecraft.network.protocol.game.ServerboundInteractPacket(villager,
|
||||
@@ -715,8 +726,9 @@ public final class VanillaProtocolClient {
|
||||
net.minecraft.core.Direction.NORTH, furnace, false), 1));
|
||||
step++;
|
||||
stepAt = now;
|
||||
} else if (step >= 8 + REPLIES.length && step <= 10 + REPLIES.length && furnaceWindow > 0
|
||||
&& now - stepAt > (step == 10 + REPLIES.length ? 12_000 : 1500)) {
|
||||
} else if (step >= 8 + REPLIES.length && step <= 10 + REPLIES.length && furnaceWindow > 0 && now - stepAt > 1500
|
||||
// The ingot out once the furnace made it (200 ticks), and its tick's other changes came.
|
||||
&& (step < 10 + REPLIES.length || smeltedAt > 0 && now - smeltedAt > 1000 || now - stepAt > 10_000 + PATIENCE)) {
|
||||
// Shift-click the ore (hotbar 0) into the input, the coal (hotbar 1) into the
|
||||
// fuel slot, and the smelted ingot out of the result; nothing claimed, so the
|
||||
// server sends every slot the click changed.
|
||||
@@ -743,9 +755,10 @@ public final class VanillaProtocolClient {
|
||||
useOn(respawnAt, net.minecraft.world.InteractionHand.MAIN_HAND, 2);
|
||||
use(position, net.minecraft.world.InteractionHand.MAIN_HAND, 3);
|
||||
snowballs = 0;
|
||||
snowballAt = 0;
|
||||
step++;
|
||||
stepAt = now;
|
||||
} else if (step == 14 + REPLIES.length && now - stepAt > 2000) {
|
||||
} else if (step == 14 + REPLIES.length && thrown(snowballAt, stepAt, now)) {
|
||||
record("play-use", "main hand on a block: " + snowballs + " snowball(s)");
|
||||
send(new ServerboundChatCommandPacket("item replace entity @s weapon.mainhand with minecraft:air"));
|
||||
send(new ServerboundChatCommandPacket("item replace entity @s weapon.offhand with minecraft:snowball 16"));
|
||||
@@ -757,9 +770,10 @@ public final class VanillaProtocolClient {
|
||||
useOn(respawnAt, net.minecraft.world.InteractionHand.OFF_HAND, 5);
|
||||
use(position, net.minecraft.world.InteractionHand.OFF_HAND, 6);
|
||||
snowballs = 0;
|
||||
snowballAt = 0;
|
||||
step++;
|
||||
stepAt = now;
|
||||
} else if (step == 16 + REPLIES.length && now - stepAt > 2000) {
|
||||
} else if (step == 16 + REPLIES.length && thrown(snowballAt, stepAt, now)) {
|
||||
record("play-use", "off hand on a block: " + snowballs + " snowball(s)");
|
||||
// An ender pearl thrown at its feet (no endermite: ThrownEnderpearl spawns one 5% of the time).
|
||||
send(new ServerboundChatCommandPacket("gamerule spawn_monsters false"));
|
||||
@@ -773,7 +787,8 @@ public final class VanillaProtocolClient {
|
||||
position.yRot(), 90.0F));
|
||||
step++;
|
||||
stepAt = now;
|
||||
} else if (step == 18 + REPLIES.length && now - stepAt > 2500) {
|
||||
} else if (step == 18 + REPLIES.length && now - stepAt > 2500
|
||||
&& (pearlTeleports > 0 && pearlHealth >= 0 || now - stepAt > PATIENCE)) {
|
||||
// ThrownEnderpearl.onHit: the thrower is teleported and takes 5 damage.
|
||||
record("play-pearl", "teleported " + pearlTeleports + " time(s), health " + pearlHealth);
|
||||
return;
|
||||
@@ -786,6 +801,14 @@ public final class VanillaProtocolClient {
|
||||
record("error", "timed out in play, step " + step + ", chunks " + chunks.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* A right click's snowballs are counted: once the first came and a second could have come with it
|
||||
* (the server spawns each in the tick it handles the click), or once the server had all the time.
|
||||
*/
|
||||
private static boolean thrown(long first, long clicked, long now) {
|
||||
return now - clicked > 2000 && (first > 0 && now - first > 1000 || now - clicked > PATIENCE);
|
||||
}
|
||||
|
||||
/** The death screen's Respawn button: true, the respawn being under way. */
|
||||
private boolean respawn() throws Exception {
|
||||
send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket(
|
||||
@@ -821,10 +844,12 @@ public final class VanillaProtocolClient {
|
||||
*/
|
||||
private String respawnLine(Received r) {
|
||||
String t = r.type;
|
||||
// Entities moving and the dead player's health land in the window between the death
|
||||
// and the respawn as each server's ticks fall: left out.
|
||||
// Entities moving, blocks the world's ticks change (a lava fall flows again once the game
|
||||
// runs) and the dead player's health land in the window between the death and the respawn
|
||||
// as each server's ticks fall: left out.
|
||||
if (t.startsWith("minecraft:move_entity") || t.equals("minecraft:rotate_head") || t.equals("minecraft:set_entity_motion")
|
||||
|| t.equals("minecraft:set_entity_data") || t.equals("minecraft:entity_position_sync") || t.equals("minecraft:set_health")) {
|
||||
|| t.equals("minecraft:set_entity_data") || t.equals("minecraft:entity_position_sync") || t.equals("minecraft:set_health")
|
||||
|| t.equals("minecraft:block_update") || t.equals("minecraft:section_blocks_update")) {
|
||||
return null;
|
||||
}
|
||||
if (t.equals("minecraft:keep_alive") || t.equals("minecraft:ping") || t.equals("minecraft:level_chunk_with_light")
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Packages the macOS launcher: Horizon.app (the binary, Info.plist, the icon), signed, then the
|
||||
# two release assets, horizon-launcher-macos-arm64.zip (what the launcher updates itself from;
|
||||
# older launchers find the binary inside it) and horizon-launcher-macos-arm64.dmg (the installer:
|
||||
# the app and an Applications link over a background, to drag one onto the other).
|
||||
# two release assets, horizon-launcher-macos-ARCH.zip (what the launcher updates itself from;
|
||||
# older launchers find the binary inside it) and horizon-launcher-macos-ARCH.dmg (the installer:
|
||||
# the app and an Applications link over a background, to drag one onto the other). ARCH is the
|
||||
# binary's: arm64 (Apple Silicon, the default) or x64 (Intel).
|
||||
#
|
||||
# packaging/macos/package.sh BINARY VERSION OUTDIR
|
||||
# packaging/macos/package.sh BINARY VERSION OUTDIR [arm64|x64]
|
||||
#
|
||||
# Needs macOS (ditto, codesign, hdiutil) and dmgbuild (pip install dmgbuild==1.6.5; $DMGBUILD
|
||||
# names it if it isn't on PATH). Signing: ad hoc by default, which needs no Apple account. With
|
||||
@@ -17,6 +18,16 @@ here=$(cd "$(dirname "$0")" && pwd)
|
||||
binary=$1
|
||||
version=$2
|
||||
out=$(mkdir -p "$3" && cd "$3" && pwd)
|
||||
arch=${4:-arm64}
|
||||
case "$arch" in
|
||||
arm64) cpu=arm64 ;;
|
||||
x64) cpu=x86_64 ;;
|
||||
*) echo "unknown architecture $arch (arm64 or x64)" >&2; exit 1 ;;
|
||||
esac
|
||||
# The asset names say which Macs the binary runs on: it must be built for that processor.
|
||||
[[ "$(lipo -archs "$binary")" == "$cpu" ]] || { echo "$binary is $(lipo -archs "$binary"), not $cpu" >&2; exit 1; }
|
||||
zip=horizon-launcher-macos-$arch.zip
|
||||
dmg=$out/horizon-launcher-macos-$arch.dmg
|
||||
app=$out/Horizon.app
|
||||
|
||||
rm -rf "$app"
|
||||
@@ -43,9 +54,9 @@ notarize=$([[ "$identity" != "-" && -n "${MACOS_NOTARY_PROFILE:-}" ]] && echo ye
|
||||
# The app first, so the zip and the .dmg both carry it stapled.
|
||||
[[ -z "$notarize" ]] || "$here/notarize.sh" "$app"
|
||||
|
||||
rm -f "$out/horizon-launcher-macos-arm64.zip" "$out/horizon-launcher-macos-arm64.dmg"
|
||||
rm -f "$out/$zip" "$dmg"
|
||||
# No resource forks or extended attributes: they would land as ._ files inside the bundle.
|
||||
(cd "$out" && ditto -c -k --norsrc --noextattr --noqtn --keepParent Horizon.app horizon-launcher-macos-arm64.zip)
|
||||
(cd "$out" && ditto -c -k --norsrc --noextattr --noqtn --keepParent Horizon.app "$zip")
|
||||
|
||||
cat > "$out/dmg.json" <<JSON
|
||||
{
|
||||
@@ -60,9 +71,8 @@ cat > "$out/dmg.json" <<JSON
|
||||
]
|
||||
}
|
||||
JSON
|
||||
"${DMGBUILD:-dmgbuild}" -s "$out/dmg.json" Horizon "$out/horizon-launcher-macos-arm64.dmg"
|
||||
"${DMGBUILD:-dmgbuild}" -s "$out/dmg.json" Horizon "$dmg"
|
||||
rm "$out/dmg.json"
|
||||
dmg=$out/horizon-launcher-macos-arm64.dmg
|
||||
if [[ "$identity" != "-" ]]; then
|
||||
codesign --force --timestamp ${keychain[@]+"${keychain[@]}"} --sign "$identity" "$dmg"
|
||||
codesign --verify --strict --verbose=2 "$dmg"
|
||||
@@ -74,4 +84,4 @@ if [[ -n "$notarize" ]]; then
|
||||
spctl --assess --verbose=2 "$app"
|
||||
spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg"
|
||||
fi
|
||||
echo "Packaged $out/horizon-launcher-macos-arm64.zip and .dmg"
|
||||
echo "Packaged $out/$zip and $dmg"
|
||||
@@ -33,9 +33,14 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
|
||||
# The archives to look for, in order: on Linux the server build, else (releases before it) the full game.
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
MACHINE=$(uname -m)
|
||||
# A shell under Rosetta 2 says x86_64 on Apple Silicon: the native build runs faster there.
|
||||
[ "$(uname -s)" = Darwin ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = 1 ] && MACHINE=arm64
|
||||
case "$(uname -s)-$MACHINE" in
|
||||
Linux-x86_64) ASSETS="horizon-server-linux-x86_64.tar.gz horizon-linux-x86_64.tar.gz" ;;
|
||||
Darwin-arm64) ASSETS=horizon-macos-arm64.zip ;;
|
||||
# Intel Macs: releases from 0.2 on (#1161).
|
||||
Darwin-x86_64) ASSETS=horizon-macos-x64.zip ;;
|
||||
*) die "no Horizon release for $(uname -s) $(uname -m) yet: build it from source (docs/server.md)" ;;
|
||||
esac
|
||||
|
||||
|
||||
+432
-1
@@ -36,6 +36,28 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- brewing: a brewing stand opens on the guest's screen; blaze powder, nether wart and a bottle the
|
||||
guest shift-clicks go to the fuel, ingredient and bottle slots, and the stand's fuel shows
|
||||
(#848);
|
||||
- loom: a loom opens on the guest's screen; the banner and dye the guest shift-clicks go to their
|
||||
slots, a pattern button the guest clicks makes the server's result, and the guest takes the
|
||||
patterned banner (#868);
|
||||
- stonecutter: a stonecutter opens on the guest's screen with the recipes of the stone the guest puts
|
||||
in; the guest picks the slab and shift-clicks the result, and the server has cut every
|
||||
stone into slabs (#868);
|
||||
- grindstone: a grindstone opens on the guest's screen; the enchanted sword the guest shift-clicks in
|
||||
shows the server's result, and the guest takes it with only its curse left and the
|
||||
experience orbs the server awards (#868);
|
||||
- anvil: an anvil opens on the guest's screen; the guest repairs a sword with iron ingots and
|
||||
renames it (ServerboundRenameItemPacket), takes it for the server's level cost, and the
|
||||
server has the repaired, renamed sword (#868);
|
||||
- enchanting: an enchanting table opens on the guest's screen with the server's offers for the
|
||||
sword the guest puts in; the guest clicks the first offer and the server enchants the
|
||||
sword for a level and a lapis, and counts it in the guest's enchant_item stat (#868);
|
||||
- smithing: a smithing table opens on the guest's screen; the guest upgrades a diamond sword to
|
||||
netherite and the server has it (#868);
|
||||
- lectern: a lectern's written book opens on the guest's screen; the guest turns its pages
|
||||
(ContainerButtonClick) and the server's lectern follows, then the guest takes the book
|
||||
and the server has it in the guest's inventory (#868);
|
||||
- cartography: a cartography table opens on the guest's screen; the guest puts in a map the host
|
||||
made and paper, takes the zoomed-out map, and the server has a new map for it (#868);
|
||||
- feedback: the guest's command feedback names the guest and shows the numbers;
|
||||
- chat: a line the host sends reaches the guest's event log (`wait-for chat`), and on the
|
||||
server thread the host window's too;
|
||||
@@ -133,7 +155,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
GUEST_NAME = 'Guest'
|
||||
# The host plays single player on its server thread (--server-thread).
|
||||
SERVER_THREAD = False
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight']
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight']
|
||||
|
||||
|
||||
class Game:
|
||||
@@ -917,6 +939,405 @@ def check_brewing(host, guest, out):
|
||||
return seen
|
||||
|
||||
|
||||
def open_station(guest, block, out, label):
|
||||
"""Place `block` two blocks south of the guest and open it: the station's position."""
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
at = (x, y, z + 2)
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
|
||||
guest.wait(10)
|
||||
guest.raw('interact', *at)
|
||||
guest.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
||||
guest.wait(10)
|
||||
guest.shot(out, label)
|
||||
return at
|
||||
|
||||
|
||||
def station_slots(guest):
|
||||
"""The open station's stacks: input slots 600 and up, the result at 650."""
|
||||
menu = guest.raw('menu')
|
||||
return {s['slot']: (s['stack']['item'], s['stack']['count']) for s in menu['slots'] if s['slot'] >= 600}
|
||||
|
||||
|
||||
def close_station(guest, at):
|
||||
guest.raw('key', 'esc', 'tap')
|
||||
guest.wait(10)
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
||||
|
||||
|
||||
def check_loom(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
for item in ('white_banner', 'red_dye'):
|
||||
guest('command', command=f'give @s minecraft:{item}')
|
||||
at = open_station(guest, 'loom', out, 'loom')
|
||||
# LoomMenu.quickMoveStack: a banner to the banner slot, a dye to the dye slot.
|
||||
for item in ('white_banner', 'red_dye'):
|
||||
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
||||
guest.wait(5)
|
||||
loaded = station_slots(guest)
|
||||
# LoomScreen's first pattern button (clickMenuButton 0): the server makes the result.
|
||||
guest.raw('click-gui', 67, 20)
|
||||
guest.wait(10)
|
||||
made = station_slots(guest).get(650)
|
||||
guest.shot(out, 'loom-pattern')
|
||||
guest.raw('click-slot', 650, 'shift')
|
||||
guest.wait(10)
|
||||
left = station_slots(guest)
|
||||
close_station(guest, at)
|
||||
# The server's copy of the banner the guest took has the red layer.
|
||||
since = guest.raw('events')['last']
|
||||
guest('command', command='data get entity @s Inventory[{id:"minecraft:white_banner"}].components')
|
||||
layer = guest.raw('wait-for', 'chat', 'color: "red"', '--timeout', 10, '--since', since).get('ok')
|
||||
counts = guest('inventory')['counts']
|
||||
seen = {'loaded': loaded, 'made': made, 'left': left, 'layer': layer, 'counts': counts}
|
||||
want = {'loaded': {600: ('minecraft:white_banner', 1), 601: ('minecraft:red_dye', 1)},
|
||||
'made': ('minecraft:white_banner', 1), 'left': {}, 'layer': True,
|
||||
'counts': {'minecraft:white_banner': 1}}
|
||||
assert seen == want, f'the loom on the guest: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
def own_reply(event):
|
||||
"""A chat event that answers the guest's own command. On the server thread the guest also gets
|
||||
its own command's `[Guest: ...]` operator broadcast, a moment later, which an answer to the next
|
||||
command must not be taken for."""
|
||||
return event.get('event') == 'chat' and not event.get('text', '').startswith(f'[{GUEST_NAME}: ')
|
||||
|
||||
|
||||
def server_count(guest, item):
|
||||
"""How many of `item` the server says the guest has (`clear` with a count of 0 only counts)."""
|
||||
since = guest.raw('events')['last']
|
||||
guest('command', command=f'clear @s minecraft:{item} 0')
|
||||
for n in range(0, 65):
|
||||
reply = guest.raw('events', '--since', since)
|
||||
lines = [e.get('text', '') for e in reply['events'] if own_reply(e)]
|
||||
for line in lines:
|
||||
if 'matching item' in line or 'No items were found' in line:
|
||||
return 0 if 'No items' in line else int(line.split('Found ')[1].split(' ')[0])
|
||||
guest.wait(2)
|
||||
raise Failed(f'no answer to clear for {item}')
|
||||
|
||||
|
||||
def check_stonecutter(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='give @s minecraft:stone 3')
|
||||
at = open_station(guest, 'stonecutter', out, 'stonecutter')
|
||||
# StonecutterMenu.quickMoveStack: stone has recipes, so it goes to the input.
|
||||
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:stone'), 'shift')
|
||||
guest.wait(10)
|
||||
recipes = guest.raw('menu')['info']['recipes']
|
||||
index = recipes.index('minecraft:stone_slab_from_stone_stonecutting')
|
||||
# StonecutterScreen's recipe grid: four columns of 16x18 cells from (52, 14).
|
||||
guest.raw('click-gui', 52 + index % 4 * 16 + 8, 14 + index // 4 * 18 + 9)
|
||||
guest.wait(10)
|
||||
picked = (guest.raw('menu')['info']['selected'], station_slots(guest).get(650))
|
||||
guest.shot(out, 'stonecutter-recipe')
|
||||
# A shift-click on the result cuts while there is stone (doClick repeats quickMoveStack).
|
||||
guest.raw('click-slot', 650, 'shift')
|
||||
guest.wait(10)
|
||||
left = station_slots(guest)
|
||||
close_station(guest, at)
|
||||
seen = {'picked': picked, 'left': left,
|
||||
'server': {item: server_count(guest, item) for item in ('stone_slab', 'stone')}}
|
||||
want = {'picked': (index, ('minecraft:stone_slab', 2)), 'left': {},
|
||||
'server': {'stone_slab': 6, 'stone': 0}}
|
||||
assert seen == want, f'the stonecutter on the guest: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
def server_reply(guest, command, *needles):
|
||||
"""The first chat line holding every needle after the guest runs `command`."""
|
||||
since = guest.raw('events')['last']
|
||||
guest('command', command=command)
|
||||
for _ in range(0, 65):
|
||||
reply = guest.raw('events', '--since', since)
|
||||
for e in reply['events']:
|
||||
line = e.get('text', '')
|
||||
if own_reply(e) and all(n in line for n in needles):
|
||||
return line
|
||||
guest.wait(2)
|
||||
raise Failed(f'no answer to {command}')
|
||||
|
||||
|
||||
def check_grindstone(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='xp set @s 0 levels')
|
||||
guest('command', command='xp set @s 0 points')
|
||||
guest('command', command='give @s minecraft:iron_sword[minecraft:enchantments={"minecraft:sharpness":3,'
|
||||
'"minecraft:vanishing_curse":1},minecraft:damage=100]')
|
||||
at = open_station(guest, 'grindstone', out, 'grindstone')
|
||||
# GrindstoneMenu.quickMoveStack: with both inputs empty, into the first.
|
||||
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:iron_sword'), 'shift')
|
||||
guest.wait(10)
|
||||
loaded = station_slots(guest)
|
||||
guest.shot(out, 'grindstone-result')
|
||||
guest.raw('click-slot', 650, 'shift')
|
||||
# The orbs (sharpness III: 12 to 23 points) fly to the guest.
|
||||
guest.wait(60)
|
||||
left = station_slots(guest)
|
||||
close_station(guest, at)
|
||||
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}].components',
|
||||
'minecraft:damage')
|
||||
# 16 points make exactly two levels and leave 0 points: count the levels too.
|
||||
xp = (server_reply(guest, 'xp query @s points', 'experience points')
|
||||
+ server_reply(guest, 'xp query @s levels', 'experience levels'))
|
||||
seen = {'loaded': loaded, 'left': left, 'curse kept': 'vanishing_curse' in sword,
|
||||
'sharpness gone': 'sharpness' not in sword,
|
||||
'damage': re.search(r'minecraft:damage"?: 100\b', sword) is not None,
|
||||
'xp': any(int(n) > 0 for n in re.findall(r'has (\d+) experience', xp))}
|
||||
want = {'loaded': {600: ('minecraft:iron_sword', 1), 650: ('minecraft:iron_sword', 1)}, 'left': {},
|
||||
'curse kept': True, 'sharpness gone': True, 'damage': True, 'xp': True}
|
||||
assert seen == want, f'the grindstone on the guest: {seen} ({sword!r}, {xp!r})'
|
||||
return seen
|
||||
|
||||
|
||||
def check_anvil(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='xp set @s 5 levels')
|
||||
guest('command', command='give @s minecraft:iron_sword[minecraft:damage=200]')
|
||||
guest('command', command='give @s minecraft:iron_ingot 3')
|
||||
at = open_station(guest, 'anvil', out, 'anvil')
|
||||
# ItemCombinerMenu.quickMoveStack: into the first input that takes it.
|
||||
for item in ('iron_sword', 'iron_ingot'):
|
||||
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
||||
guest.wait(5)
|
||||
# The box shows "Iron Sword": erase it and type the new name.
|
||||
for _ in range(len('Iron Sword')):
|
||||
guest.raw('type-back')
|
||||
guest.raw('type', 'Blade')
|
||||
guest.wait(10)
|
||||
menu = guest.raw('menu')
|
||||
offered = (menu['info']['anvil_cost'], station_slots(guest).get(650))
|
||||
guest.shot(out, 'anvil-rename')
|
||||
guest.raw('click-slot', 650, 'shift')
|
||||
guest.wait(10)
|
||||
left = station_slots(guest)
|
||||
close_station(guest, at)
|
||||
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}].components',
|
||||
'minecraft:damage')
|
||||
levels = server_reply(guest, 'xp query @s levels', 'experience levels')
|
||||
seen = {'offered': offered, 'left': left, 'name': 'Blade' in sword,
|
||||
'damage': re.search(r'minecraft:damage"?: 14\b', sword) is not None,
|
||||
'levels': int(re.search(r'has (\d+) experience', levels).group(1)),
|
||||
'ingots': server_count(guest, 'iron_ingot')}
|
||||
# Three ingots repair 62 each (a quarter of 250), plus the name: four levels.
|
||||
want = {'offered': (4, ('minecraft:iron_sword', 1)), 'left': {}, 'name': True, 'damage': True,
|
||||
'levels': 1, 'ingots': 0}
|
||||
assert seen == want, f'the anvil on the guest: {seen} ({sword!r})'
|
||||
return seen
|
||||
|
||||
|
||||
def check_smithing(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
for item in ('netherite_upgrade_smithing_template', 'diamond_sword', 'netherite_ingot'):
|
||||
guest('command', command=f'give @s minecraft:{item}')
|
||||
at = open_station(guest, 'smithing_table', out, 'smithing')
|
||||
# SmithingMenu.canMoveIntoInputSlots: each to the slot whose recipe test takes it.
|
||||
for item in ('netherite_upgrade_smithing_template', 'diamond_sword', 'netherite_ingot'):
|
||||
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
||||
guest.wait(5)
|
||||
guest.wait(5)
|
||||
loaded = station_slots(guest)
|
||||
guest.shot(out, 'smithing-result')
|
||||
guest.raw('click-slot', 650, 'shift')
|
||||
guest.wait(10)
|
||||
left = station_slots(guest)
|
||||
close_station(guest, at)
|
||||
seen = {'loaded': loaded, 'left': left,
|
||||
'server': {item: server_count(guest, item) for item in ('netherite_sword', 'diamond_sword', 'netherite_ingot')}}
|
||||
want = {'loaded': {600: ('minecraft:netherite_upgrade_smithing_template', 1), 601: ('minecraft:diamond_sword', 1),
|
||||
602: ('minecraft:netherite_ingot', 1), 650: ('minecraft:netherite_sword', 1)},
|
||||
'left': {}, 'server': {'netherite_sword': 1, 'diamond_sword': 0, 'netherite_ingot': 0}}
|
||||
assert seen == want, f'the smithing table on the guest: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
def check_enchanting(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='xp set @s 30 levels')
|
||||
guest('command', command='give @s minecraft:iron_sword')
|
||||
guest('command', command='give @s minecraft:lapis_lazuli 3')
|
||||
# EnchantmentMenu awards the enchant_item stat; a stat objective shows the guest's count.
|
||||
guest('command', command='scoreboard objectives add lanench minecraft.custom:minecraft.enchant_item')
|
||||
at = open_station(guest, 'enchanting_table', out, 'enchanting')
|
||||
# EnchantmentMenu.quickMoveStack: the sword to the item slot, the lapis to its own.
|
||||
for item in ('iron_sword', 'lapis_lazuli'):
|
||||
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
||||
guest.wait(5)
|
||||
guest.wait(10)
|
||||
costs = guest.raw('menu')['info']['costs']
|
||||
guest.shot(out, 'enchanting-offers')
|
||||
# EnchantmentScreen's first offer button (clickMenuButton 0).
|
||||
guest.raw('click-gui', 110, 23)
|
||||
guest.wait(10)
|
||||
left = station_slots(guest)
|
||||
close_station(guest, at)
|
||||
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}]', 'iron_sword')
|
||||
levels = server_reply(guest, 'xp query @s levels', 'experience levels')
|
||||
stat = server_reply(guest, 'scoreboard players get @s lanench', 'lanench')
|
||||
guest('command', command='scoreboard objectives remove lanench')
|
||||
seen = {'offered': costs[0] > 0, 'enchanted': 'minecraft:enchantments' in sword,
|
||||
'left': left.get(601), 'levels': int(re.search(r'has (\d+) experience', levels).group(1)),
|
||||
'lapis': server_count(guest, 'lapis_lazuli'), 'stat': ' has 1 ' in stat}
|
||||
want = {'offered': True, 'enchanted': True, 'left': ('minecraft:lapis_lazuli', 2), 'levels': 29, 'lapis': 2,
|
||||
'stat': True}
|
||||
assert seen == want, f'the enchanting table on the guest: {seen} ({costs}, {sword!r})'
|
||||
return seen
|
||||
|
||||
|
||||
def check_beacon(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='effect clear @s')
|
||||
guest('command', command='give @s minecraft:iron_ingot')
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
at = (x, y, z + 2)
|
||||
# A one-level pyramid under the beacon; its levels come with the first base check (80 ticks).
|
||||
guest('command', command=f'fill {x - 1} {y - 1} {z + 1} {x + 1} {y - 1} {z + 3} minecraft:iron_block')
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:beacon')
|
||||
guest.wait(100)
|
||||
guest.raw('interact', *at)
|
||||
guest.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
||||
guest.wait(10)
|
||||
# BeaconMenu.quickMoveStack: the ingot to the payment slot.
|
||||
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:iron_ingot'), 'shift')
|
||||
guest.wait(10)
|
||||
paid = station_slots(guest).get(600)
|
||||
# BeaconScreen (54 px wider, 27 to the left): Haste, the first level's second power, then Done,
|
||||
# which sends ServerboundSetBeaconPacket and closes the menu.
|
||||
guest.raw('click-gui', 77 - 27 + 11, 22 + 11)
|
||||
guest.wait(5)
|
||||
guest.shot(out, 'beacon')
|
||||
guest.raw('click-gui', 164 - 27 + 11, 107 + 11)
|
||||
guest.wait(10)
|
||||
screen = guest('state').get('screen')
|
||||
effects = []
|
||||
# BeaconBlockEntity.applyEffects runs every 80 ticks.
|
||||
for _ in range(12):
|
||||
guest.wait(20)
|
||||
effects = effect_ids(guest)
|
||||
if 'haste' in effects:
|
||||
break
|
||||
seen = {'paid': paid, 'haste': 'haste' in effects, 'ingots': server_count(guest, 'iron_ingot'),
|
||||
'screen': screen}
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
||||
want = {'paid': ('minecraft:iron_ingot', 1), 'haste': True, 'ingots': 0, 'screen': 'playing'}
|
||||
assert seen == want, f'the beacon on the guest: {seen} ({effects})'
|
||||
return seen
|
||||
|
||||
|
||||
def check_crafter(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='give @s minecraft:oak_planks 4')
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
at = (x, y, z + 2)
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:crafter')
|
||||
guest.wait(10)
|
||||
guest.raw('interact', *at)
|
||||
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
||||
guest.wait(10)
|
||||
# CrafterScreen.slotClicked: an empty-handed click disables the first slot
|
||||
# (ServerboundContainerSlotStateChangedPacket); the planks then skip it.
|
||||
guest.raw('click-slot', 300)
|
||||
guest.wait(5)
|
||||
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:oak_planks'), 'shift')
|
||||
guest.wait(10)
|
||||
guest.shot(out, 'crafter')
|
||||
guest.raw('key', 'esc', 'tap')
|
||||
guest.wait(10)
|
||||
# Opening it again shows the server's crafter.
|
||||
items = open_container(guest, at, out, 'crafter-after')
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
||||
seen = {'items': items[0] if isinstance(items, tuple) else items, 'planks': server_count(guest, 'oak_planks')}
|
||||
want = {'items': {1: 'minecraft:oak_planks'}, 'planks': 0}
|
||||
assert seen == want, f'the crafter on the guest: {seen}'
|
||||
return seen
|
||||
|
||||
|
||||
def check_lectern(host, guest, out):
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
at = (x, y, z + 2)
|
||||
book = ('{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":'
|
||||
'{title:"Lan",author:"Host",pages:["one","two","three"]}}}')
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:lectern[has_book=true]{{Book:{book}}}')
|
||||
guest.wait(10)
|
||||
guest.raw('interact', *at)
|
||||
guest.raw('wait-for', 'screen', 'book', '--timeout', 10)
|
||||
guest.wait(10)
|
||||
opened = guest.raw('book')
|
||||
guest.shot(out, 'lectern')
|
||||
# LecternScreen's page arrows: ContainerButtonClick 2 (forward) and 1 (back); the server's
|
||||
# LecternMenu.setData clamps to the book's last page and sends the page back (data slot 0).
|
||||
for button in ('next', 'next', 'next', 'back'):
|
||||
guest.raw('book', button)
|
||||
guest.wait(5)
|
||||
guest.wait(10)
|
||||
turned = guest.raw('book')
|
||||
page = server_reply(guest, f'data get block {at[0]} {at[1]} {at[2]} Page', 'block data')
|
||||
guest.shot(out, 'lectern-page')
|
||||
# Take Book: ContainerButtonClick 3; the server empties the lectern and closes the menu.
|
||||
guest.raw('book', 'take')
|
||||
guest.wait(20)
|
||||
screen = guest('state').get('screen')
|
||||
held = server_count(guest, 'written_book')
|
||||
has_book = server_reply(guest, f'execute if block {at[0]} {at[1]} {at[2]} minecraft:lectern[has_book=false]',
|
||||
'Test passed')
|
||||
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
||||
seen = {'opened': (opened.get('page'), opened.get('pages')), 'turned': turned.get('page'),
|
||||
'server page': int(re.search(r'(-?\d+)\s*$', page).group(1)), 'screen': screen, 'held': held,
|
||||
'emptied': 'Test passed' in has_book}
|
||||
want = {'opened': (0, 3), 'turned': 1, 'server page': 1, 'screen': 'playing', 'held': 1, 'emptied': True}
|
||||
assert seen == want, f'the lectern on the guest: {seen} ({page!r})'
|
||||
return seen
|
||||
|
||||
|
||||
def check_cartography(host, guest, out):
|
||||
# The host makes a map (EmptyMapItem.use), the guest gets a copy of it.
|
||||
host('command', command='clear @s')
|
||||
host('command', command='item replace entity @s weapon.mainhand with minecraft:map')
|
||||
host.wait(5)
|
||||
host.raw('use-item')
|
||||
host.wait(20)
|
||||
made = server_reply(host, 'data get entity @s Inventory[{id:"minecraft:filled_map"}].components', 'map_id')
|
||||
map_id = int(re.search(r'map_id"?: (\d+)', made).group(1))
|
||||
host('command', command='clear @s')
|
||||
guest('command', command='gamemode survival')
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command=f'give @s minecraft:filled_map[minecraft:map_id={map_id}]')
|
||||
guest('command', command='give @s minecraft:paper')
|
||||
at = open_station(guest, 'cartography_table', out, 'cartography')
|
||||
# CartographyTableMenu.quickMoveStack: a map with an id to the map slot, paper to the other.
|
||||
for item in ('filled_map', 'paper'):
|
||||
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
||||
guest.wait(5)
|
||||
guest.wait(10)
|
||||
loaded = station_slots(guest)
|
||||
guest.shot(out, 'cartography-result')
|
||||
# The result's onTake uses the map and the paper; MapItem.onCraftedPostProcess gives the zoomed
|
||||
# out map a new id on the server.
|
||||
guest.raw('click-slot', 650, 'shift')
|
||||
guest.wait(10)
|
||||
left = station_slots(guest)
|
||||
close_station(guest, at)
|
||||
taken = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:filled_map"}].components', 'map_id')
|
||||
new_id = int(re.search(r'map_id"?: (\d+)', taken).group(1))
|
||||
seen = {'loaded': loaded, 'left': left, 'new map': new_id != map_id,
|
||||
'server': {item: server_count(guest, item) for item in ('filled_map', 'paper')}}
|
||||
want = {'loaded': {600: ('minecraft:filled_map', 1), 601: ('minecraft:paper', 1),
|
||||
650: ('minecraft:filled_map', 1)},
|
||||
'left': {}, 'new map': True, 'server': {'filled_map': 1, 'paper': 0}}
|
||||
assert seen == want, f'the cartography table on the guest: {seen} ({made!r}, {taken!r})'
|
||||
return seen
|
||||
|
||||
|
||||
def slot_point(game, slot):
|
||||
"""The GUI point in the middle of slot `slot` of the open screen (`ui`'s slot-N)."""
|
||||
for w in game.raw('ui')['widgets']:
|
||||
@@ -2152,6 +2573,16 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name
|
||||
'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles, 'chat': check_chat,
|
||||
'machines': check_machines,
|
||||
'brewing': check_brewing,
|
||||
'loom': check_loom,
|
||||
'stonecutter': check_stonecutter,
|
||||
'grindstone': check_grindstone,
|
||||
'anvil': check_anvil,
|
||||
'smithing': check_smithing,
|
||||
'enchanting': check_enchanting,
|
||||
'beacon': check_beacon,
|
||||
'crafter': check_crafter,
|
||||
'lectern': check_lectern,
|
||||
'cartography': check_cartography,
|
||||
'hurt': check_hurt,
|
||||
'commands': check_commands,
|
||||
'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls,
|
||||
|
||||
@@ -10,7 +10,17 @@ click: QUICK_MOVE on every slot of the clicked slot's container holding the
|
||||
item the first click moved) over the player inventory,
|
||||
crafting table, chests, hopper, dispenser, shulker box, the furnaces, the
|
||||
brewing stand, the crafter (the client's menu, and the server's over the block
|
||||
entity with slot state packets), the loom (with its pattern buttons) and the
|
||||
entity with slot state packets), the loom (with its pattern buttons), the
|
||||
stonecutter (with its recipe buttons), the grindstone (enchanted, damaged and
|
||||
cursed tools and books), the anvil (repairs, enchantment merges and renames,
|
||||
with the player's levels), the smithing table (upgrades and trims), the
|
||||
enchanting table (its button checks against the costs, lapis and levels), the
|
||||
beacon (its payment slot and ServerboundSetBeaconPacket against the pyramid's
|
||||
levels), the
|
||||
cartography table (over a level with locked, scaled and missing maps: its
|
||||
results, and the scaled and locked copies a take makes), the lectern (over the
|
||||
block entity: its page buttons, clamped to the book's pages, and Take Book,
|
||||
with the inventory compared too) and the
|
||||
server's furnace menus over their block entities (`*_be`: a new input restarts
|
||||
cooking, other clicks keep its progress). After
|
||||
every click the slots, carried stack, data slots and drops are compared.
|
||||
@@ -26,8 +36,9 @@ except ImportError:
|
||||
|
||||
MENUS = {'inventory': 46, 'crafting': 46, 'chest1': 45, 'chest2': 54, 'chest3': 63, 'chest4': 72, 'chest5': 81,
|
||||
'chest6': 90, 'hopper': 41, 'dispenser': 45, 'shulker': 63, 'furnace': 39, 'blast_furnace': 39, 'smoker': 39, 'brewing': 41, 'crafter': 46,
|
||||
'crafter_be': 46, 'loom': 40, 'furnace_be': 39, 'blast_furnace_be': 39, 'smoker_be': 39}
|
||||
'crafter_be': 46, 'loom': 40, 'stonecutter': 38, 'grindstone': 39, 'anvil': 39, 'smithing': 40, 'enchanting': 38, 'beacon': 37, 'cartography': 39, 'lectern': 1, 'furnace_be': 39, 'blast_furnace_be': 39, 'smoker_be': 39}
|
||||
DATA = {'furnace': 4, 'blast_furnace': 4, 'smoker': 4, 'brewing': 4, 'crafter': 10, 'crafter_be': 10, 'loom': 1,
|
||||
'stonecutter': 1, 'enchanting': 10, 'beacon': 3, 'lectern': 1,
|
||||
'furnace_be': 4, 'blast_furnace_be': 4, 'smoker_be': 4}
|
||||
DYES = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple',
|
||||
'blue', 'brown', 'green', 'red', 'black']
|
||||
@@ -40,6 +51,27 @@ COMMON = ['stone', 'dirt', 'cobblestone', 'oak_planks', 'stick', 'coal', 'iron_i
|
||||
'raw_iron', 'potato', 'beef', 'chicken', 'sticky_piston', 'blaze_rod', 'lava_bucket', 'oak_sapling',
|
||||
'white_dye', 'red_dye', 'torch', 'crafting_table', 'furnace', 'chest', 'glass_bottle', 'nether_wart', 'blaze_powder',
|
||||
'redstone', 'gunpowder', 'sugar', 'potion', 'splash_potion', 'lingering_potion', 'fermented_spider_eye', 'dragon_breath']
|
||||
# Stonecutter inputs (several recipes each), and a block it doesn't cut.
|
||||
CUTTABLE = ['stone', 'cobblestone', 'andesite', 'granite', 'copper_block', 'sandstone', 'quartz_block', 'deepslate',
|
||||
'stone_bricks', 'oak_planks']
|
||||
# Grindstone inputs: tools and armor, books, and their enchantments (curses too).
|
||||
GRINDABLE = ['iron_sword', 'diamond_pickaxe', 'bow', 'iron_helmet', 'elytra', 'shield', 'fishing_rod', 'trident']
|
||||
# Beacon powers (BEACON_EFFECTS by level, and one no level gives) and payments.
|
||||
POWERS = ['speed', 'haste', 'resistance', 'jump_boost', 'strength', 'regeneration', 'night_vision']
|
||||
PAYMENTS = ['iron_ingot', 'gold_ingot', 'emerald', 'diamond', 'netherite_ingot']
|
||||
ENCHANTS = ['sharpness', 'unbreaking', 'mending', 'efficiency', 'protection', 'power', 'looting', 'fortune',
|
||||
'binding_curse', 'vanishing_curse']
|
||||
|
||||
|
||||
# Anvil renames (ServerboundRenameItemPacket): blank, too long, a section sign, an item's own name.
|
||||
NAMES = ['', ' ', 'Blade', 'Iron Sword', 'Diamond Pickaxe', 'Enchanted Book', '\u00a7cRed', 'x' * 50, 'x' * 51]
|
||||
# Smithing inputs: templates, bases and additions (trim materials and netherite).
|
||||
TEMPLATES = ['netherite_upgrade_smithing_template', 'coast_armor_trim_smithing_template',
|
||||
'sentry_armor_trim_smithing_template', 'bolt_armor_trim_smithing_template']
|
||||
BASES = ['diamond_sword', 'diamond_chestplate', 'diamond_pickaxe', 'iron_helmet', 'iron_chestplate', 'netherite_boots',
|
||||
'leather_leggings', 'golden_helmet', 'turtle_helmet', 'diamond_hoe']
|
||||
ADDITIONS = ['netherite_ingot', 'iron_ingot', 'gold_ingot', 'emerald', 'redstone', 'quartz', 'amethyst_shard',
|
||||
'lapis_lazuli', 'copper_ingot', 'diamond']
|
||||
|
||||
|
||||
def quote(text):
|
||||
@@ -97,6 +129,96 @@ class Gen:
|
||||
return snbt(rng.choice(pool), 1)
|
||||
return self.stack()
|
||||
|
||||
def cut_stack(self):
|
||||
"""Mostly something the stonecutter cuts, sometimes anything."""
|
||||
rng = self.rng
|
||||
if rng.random() < 0.75:
|
||||
return snbt('minecraft:' + rng.choice(CUTTABLE), rng.choice([1, 1, 2, 3, 10, 63, 64]))
|
||||
return self.stack()
|
||||
|
||||
def grind_stack(self):
|
||||
"""Mostly an enchanted or damaged tool or an enchanted book, sometimes anything."""
|
||||
rng = self.rng
|
||||
roll = rng.random()
|
||||
if roll > 0.8:
|
||||
return self.stack()
|
||||
levels = ','.join('"minecraft:%s":%d' % (e, rng.randrange(1, 4))
|
||||
for e in rng.sample(ENCHANTS, rng.choice([0, 1, 1, 2, 3])))
|
||||
comps = []
|
||||
if roll < 0.25:
|
||||
item = 'minecraft:' + rng.choice(['enchanted_book', 'enchanted_book', 'book'])
|
||||
if levels:
|
||||
comps.append('"minecraft:stored_enchantments":{%s}' % levels)
|
||||
count = 1 if rng.random() < 0.85 else 2
|
||||
else:
|
||||
item = 'minecraft:' + rng.choice(GRINDABLE)
|
||||
info = self.items.get(item, {})
|
||||
if levels:
|
||||
comps.append('"minecraft:enchantments":{%s}' % levels)
|
||||
if info.get('minecraft:max_damage') and rng.random() < 0.6:
|
||||
comps.append('"minecraft:damage":%d' % rng.randrange(0, info['minecraft:max_damage']))
|
||||
count = 1
|
||||
if rng.random() < 0.2:
|
||||
comps.append('"minecraft:repair_cost":%d' % rng.choice([1, 3, 7]))
|
||||
body = ',components:{%s}' % ','.join(comps) if comps else ''
|
||||
return '{id:%s,count:%d%s}' % (quote(item), count, body)
|
||||
|
||||
def anvil_stack(self):
|
||||
"""A tool to repair or enchant, an enchanted book, a repair material, a named item or anything."""
|
||||
rng = self.rng
|
||||
roll = rng.random()
|
||||
if roll < 0.45:
|
||||
stack = self.grind_stack()
|
||||
elif roll < 0.65:
|
||||
item = rng.choice(['iron_ingot', 'diamond', 'leather', 'oak_planks', 'phantom_membrane', 'string'])
|
||||
stack = snbt('minecraft:' + item, rng.choice([1, 1, 2, 3, 5, 64]))
|
||||
else:
|
||||
stack = self.stack()
|
||||
if rng.random() < 0.15:
|
||||
name = '"minecraft:custom_name":"%s"' % rng.choice(['Blade', 'Iron Sword', 'Old'])
|
||||
stack = stack.replace(',components:{', ',components:{%s,' % name) if ',components:{' in stack \
|
||||
else stack[:-1] + ',components:{%s}}' % name
|
||||
return stack
|
||||
|
||||
def smith_stack(self, slot=None):
|
||||
"""A template, a base or an addition (for `slot`, else any), sometimes anything."""
|
||||
rng = self.rng
|
||||
if rng.random() < 0.15:
|
||||
return self.stack()
|
||||
slot = rng.randrange(3) if slot is None else slot
|
||||
pool = [TEMPLATES, BASES, ADDITIONS][slot]
|
||||
item = 'minecraft:' + rng.choice(pool)
|
||||
count = 1 if slot == 1 else rng.choice([1, 1, 2, 5, 64])
|
||||
if slot == 1 and rng.random() < 0.3:
|
||||
return '{id:%s,count:1,components:{"minecraft:enchantments":{"minecraft:unbreaking":2}}}' % quote(item)
|
||||
return snbt(item, count)
|
||||
|
||||
def carto_stack(self):
|
||||
"""A filled map (some ids without data), paper, an empty map, a glass pane or anything."""
|
||||
rng = self.rng
|
||||
roll = rng.random()
|
||||
if roll < 0.4:
|
||||
return '{id:"minecraft:filled_map",count:%d,components:{"minecraft:map_id":%d}}' % (
|
||||
rng.choice([1, 1, 2, 3, 64]), rng.randrange(0, 7))
|
||||
if roll < 0.75:
|
||||
return snbt('minecraft:' + rng.choice(['paper', 'map', 'glass_pane']), rng.choice([1, 1, 2, 5, 64]))
|
||||
if roll < 0.78:
|
||||
# Something else with a map id: the map slot takes it.
|
||||
return '{id:"minecraft:paper",count:1,components:{"minecraft:map_id":%d}}' % rng.randrange(0, 7)
|
||||
return self.stack()
|
||||
|
||||
def book(self):
|
||||
"""A written or writable book with a few pages, or something else."""
|
||||
rng = self.rng
|
||||
pages = ','.join('"p%d"' % i for i in range(rng.choice([0, 1, 2, 3, 5])))
|
||||
roll = rng.random()
|
||||
if roll < 0.45:
|
||||
return ('{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":'
|
||||
'{title:"T",author:"A",pages:[%s]}}}' % pages)
|
||||
if roll < 0.85:
|
||||
return '{id:"minecraft:writable_book",count:1,components:{"minecraft:writable_book_content":{pages:[%s]}}}' % pages
|
||||
return self.stack()
|
||||
|
||||
def recipe(self, slots, width, start=1):
|
||||
"""Fills the crafting grid (slots `start`..) with a small known recipe."""
|
||||
rng = self.rng
|
||||
@@ -123,6 +245,50 @@ class Gen:
|
||||
# The result: take it, shift it out, throw it.
|
||||
out.append([3, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'THROW', 'SWAP'])])
|
||||
continue
|
||||
if menu == 'stonecutter' and rng.random() < 0.25:
|
||||
# A recipe button (clickMenuButton), sometimes out of range or the selected one.
|
||||
out.append([rng.choice([0, 0, 1, 2, 3, 5, 10, 30, -1]), 0, 'BUTTON'])
|
||||
continue
|
||||
if menu == 'stonecutter' and rng.random() < 0.2:
|
||||
out.append([1, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'QUICK_MOVE', 'THROW', 'SWAP'])])
|
||||
continue
|
||||
if menu == 'anvil' and rng.random() < 0.15:
|
||||
out.append([0, 0, 'RENAME', rng.choice(NAMES)])
|
||||
continue
|
||||
if menu in ('anvil', 'smithing') and rng.random() < 0.2:
|
||||
result = 2 if menu == 'anvil' else 3
|
||||
out.append([result, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'QUICK_MOVE', 'THROW', 'SWAP'])])
|
||||
continue
|
||||
if menu == 'enchanting' and rng.random() < 0.25:
|
||||
# An offer button (clickMenuButton), sometimes out of range.
|
||||
out.append([rng.choice([0, 1, 2, 2, 3, -1]), 0, 'BUTTON'])
|
||||
continue
|
||||
if menu == 'beacon' and rng.random() < 0.3:
|
||||
# ServerboundSetBeaconPacket: powers, valid or not, or none.
|
||||
pick = lambda: 'minecraft:' + rng.choice(POWERS) if rng.random() < 0.8 else None
|
||||
primary, secondary = pick(), pick()
|
||||
if primary is None and secondary not in (None, 'minecraft:regeneration', 'minecraft:night_vision'):
|
||||
# validateEffects compares a null primary (Holder.equals: a NullPointerException).
|
||||
primary = secondary
|
||||
out.append([0, 0, 'SET_BEACON', primary, secondary])
|
||||
continue
|
||||
if menu == 'grindstone' and rng.random() < 0.2:
|
||||
out.append([2, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'QUICK_MOVE', 'THROW', 'SWAP'])])
|
||||
continue
|
||||
if menu == 'cartography' and rng.random() < 0.3:
|
||||
# The result: take it, shift it out, throw it, swap it, clone it.
|
||||
out.append([2, rng.randrange(0, 2), rng.choice(['PICKUP', 'QUICK_MOVE', 'QUICK_MOVE', 'THROW', 'SWAP', 'CLONE'])])
|
||||
continue
|
||||
if menu == 'lectern':
|
||||
# The page buttons, a page jump, Take Book, and buttons it doesn't have; now and
|
||||
# then a click on the book's slot. Not SWAP or the drags: vanilla's bookAccess
|
||||
# hands out the block entity's own stack, which the swapped slot then shares.
|
||||
if rng.random() < 0.7:
|
||||
out.append([rng.choice([1, 1, 2, 2, 2, 3, 100, 101, 102, 104, 0, 4, 99, -1]), 0, 'BUTTON'])
|
||||
else:
|
||||
out.append([rng.choice([0, 0, -999]), rng.randrange(0, 2),
|
||||
rng.choice(['PICKUP', 'THROW', 'QUICK_MOVE', 'CLONE'])])
|
||||
continue
|
||||
if menu == 'crafter_be' and rng.random() < 0.2:
|
||||
out.append([rng.choice([0, 1, 4, 8, 8, 9, -1]), rng.randrange(0, 2), 'SLOT_STATE'])
|
||||
continue
|
||||
@@ -183,6 +349,58 @@ def main():
|
||||
slots[2] = g.loom_stack(0.6) if rng.random() < 0.4 else rng.choice(['-', '-', g.loom_stack()])
|
||||
# The result slot follows the inputs; a fixture stack there is rare.
|
||||
slots[3] = '-' if rng.random() < 0.9 else g.loom_stack()
|
||||
if menu == 'stonecutter':
|
||||
for i in rng.sample(range(2, size), 8):
|
||||
slots[i] = g.cut_stack()
|
||||
slots[0] = g.cut_stack() if rng.random() < 0.8 else '-'
|
||||
# The result follows the input and the selected recipe; a fixture stack there is rare.
|
||||
slots[1] = '-' if rng.random() < 0.9 else g.cut_stack()
|
||||
if menu == 'grindstone':
|
||||
for i in rng.sample(range(3, size), 10):
|
||||
slots[i] = g.grind_stack()
|
||||
slots[0] = g.grind_stack() if rng.random() < 0.7 else '-'
|
||||
slots[1] = g.grind_stack() if rng.random() < 0.6 else '-'
|
||||
# Two of the same tool, so mergeItems runs.
|
||||
if slots[0] != '-' and rng.random() < 0.3:
|
||||
slots[1] = slots[0] if rng.random() < 0.4 else g.grind_stack()
|
||||
# The result follows the inputs; a fixture stack there is rare.
|
||||
slots[2] = '-' if rng.random() < 0.9 else g.grind_stack()
|
||||
if menu == 'anvil':
|
||||
for i in rng.sample(range(3, size), 10):
|
||||
slots[i] = g.anvil_stack()
|
||||
slots[0] = g.anvil_stack() if rng.random() < 0.8 else '-'
|
||||
slots[1] = g.anvil_stack() if rng.random() < 0.6 else '-'
|
||||
if slots[0] != '-' and rng.random() < 0.25:
|
||||
slots[1] = slots[0]
|
||||
slots[2] = '-' if rng.random() < 0.9 else g.anvil_stack()
|
||||
if menu == 'smithing':
|
||||
for i in rng.sample(range(4, size), 12):
|
||||
slots[i] = g.smith_stack()
|
||||
for i in range(3):
|
||||
slots[i] = g.smith_stack(i) if rng.random() < 0.75 else '-'
|
||||
slots[3] = '-' if rng.random() < 0.9 else g.smith_stack()
|
||||
if menu == 'enchanting':
|
||||
lapis = lambda: snbt('minecraft:lapis_lazuli', rng.choice([1, 2, 3, 5, 64]))
|
||||
enchantable = lambda: snbt('minecraft:' + rng.choice(['iron_sword', 'book', 'diamond_pickaxe', 'bow',
|
||||
'golden_helmet', 'fishing_rod']), 1)
|
||||
for i in rng.sample(range(2, size), 10):
|
||||
slots[i] = rng.choice([lapis, enchantable, g.grind_stack])()
|
||||
slots[0] = rng.choice([enchantable, enchantable, g.grind_stack, g.stack])() if rng.random() < 0.8 else '-'
|
||||
slots[1] = lapis() if rng.random() < 0.7 else rng.choice(['-', g.stack()])
|
||||
if menu == 'beacon':
|
||||
pay = lambda: snbt('minecraft:' + rng.choice(PAYMENTS), rng.choice([1, 1, 1, 2, 64]))
|
||||
for i in rng.sample(range(1, size), 8):
|
||||
slots[i] = pay()
|
||||
slots[0] = pay() if rng.random() < 0.7 else rng.choice(['-', g.stack()])
|
||||
if menu == 'cartography':
|
||||
for i in rng.sample(range(3, size), 12):
|
||||
slots[i] = g.carto_stack()
|
||||
slots[0] = g.carto_stack() if rng.random() < 0.8 else '-'
|
||||
slots[1] = g.carto_stack() if rng.random() < 0.75 else '-'
|
||||
# The result follows the inputs; a fixture stack there is rare.
|
||||
slots[2] = '-' if rng.random() < 0.9 else g.carto_stack()
|
||||
if menu == 'lectern':
|
||||
slots[0] = g.book() if rng.random() < 0.85 else '-'
|
||||
if menu == 'crafter_be':
|
||||
slots[45] = '-'
|
||||
if rng.random() < 0.6:
|
||||
@@ -208,13 +426,36 @@ def main():
|
||||
return [rng.choice([0, 37, 1600]), 1600, rng.choice([0, 1, 57, 199]), rng.choice([200, 100])][i]
|
||||
if menu in ('crafter', 'crafter_be'):
|
||||
return rng.choice([0, 0, 1])
|
||||
if menu == 'loom':
|
||||
if menu in ('loom', 'stonecutter'):
|
||||
return rng.choice([-1, -1, 0, 0, 1, 2, 3, 30])
|
||||
if menu == 'lectern':
|
||||
return rng.choice([-1, 0, 0, 1, 2, 4, 9])
|
||||
if menu == 'beacon':
|
||||
# the levels, then the powers (encodeEffect: id + 1)
|
||||
return rng.choice([0, 1, 2, 3, 4, 4]) if i == 0 else rng.choice([0, 0, 2, 4, 11])
|
||||
if menu == 'enchanting':
|
||||
# costs, the seed, then the clues
|
||||
return [rng.choice([0, 1, 2, 5, 15, 30]) for _ in range(3)][0] if i < 3 else \
|
||||
rng.randrange(-2**15, 2**15) if i == 3 else rng.choice([-1, -1, 0, 3, 12])
|
||||
return 0
|
||||
carried = (g.loom_stack() if menu == 'loom' else g.stack()) if rng.random() < 0.5 else '-'
|
||||
carried = {'loom': g.loom_stack, 'stonecutter': g.cut_stack, 'grindstone': g.grind_stack,
|
||||
'anvil': g.anvil_stack, 'smithing': g.smith_stack}.get(menu, g.stack)() if rng.random() < 0.5 else '-'
|
||||
if carried_food is not None:
|
||||
carried = carried_food
|
||||
cases.append({'menu': menu, 'creative': rng.random() < 0.25, 'slots': slots,
|
||||
if menu == 'lectern':
|
||||
carried = '-'
|
||||
extra = {}
|
||||
if menu == 'cartography':
|
||||
# Maps 0-4 exist (5 and 6 don't): [id, scale, locked].
|
||||
extra['maps'] = [[i, rng.choice([0, 0, 1, 3, 4]), rng.random() < 0.25] for i in range(5)]
|
||||
if rng.random() < 0.5:
|
||||
carried = g.carto_stack()
|
||||
if menu == 'lectern':
|
||||
# The inventory Take Book adds to (full, so the book drops, now and then).
|
||||
density = rng.choice([0.0, 0.3, 1.0])
|
||||
extra['inventory'] = [g.stack() if rng.random() < density else '-' for _ in range(36)]
|
||||
cases.append({'menu': menu, 'creative': rng.random() < 0.25, 'slots': slots, **extra,
|
||||
'levels': rng.choice([0, 1, 3, 8, 30, 39, 40]),
|
||||
'carried': carried, 'data': [datum(i) for i in range(DATA.get(menu, 0))],
|
||||
'ops': g.ops(size, menu, slots)})
|
||||
(work / 'cases.jsonl').write_text(''.join(json.dumps(c) + '\n' for c in cases))
|
||||
|
||||
@@ -195,13 +195,34 @@ def run_client(binary, build, port, label, token, discovery):
|
||||
PLAYER_LINE = re.compile(r'^\[\d\d:\d\d:\d\d\] \[([^\]]+)\]: (.*(?:Tester|Username|Disconnecting|verify username).*)$')
|
||||
|
||||
|
||||
def player_lines(lines):
|
||||
def player_lines(lines, starts=()):
|
||||
"""The lines about the player, login by login (from each index in `starts` to the next), with two races of
|
||||
vanilla's own taken out:
|
||||
- the server's thread and the login's authenticator each log in their own order, but which of the two logs
|
||||
first is a race (the authenticator disconnects a player, then logs why, while the server thread may already
|
||||
log the lost connection): each login's lines are grouped by thread;
|
||||
- a player the server disconnects ("Disconnecting ...: reason") closes the connection once it reads why, and
|
||||
when that comes before the server closes it, the lost connection is logged as "Disconnected": the kick's
|
||||
reason stands for both."""
|
||||
def thread(line):
|
||||
# `[User Authenticator #2/INFO]`: the thread, without the level; the login's authenticator first.
|
||||
name = (m := PLAYER_LINE.match(line)) and m.group(1).rsplit('/', 1)[0] or ''
|
||||
return name == 'Server thread', name
|
||||
|
||||
bounds = [0, *starts, len(lines)]
|
||||
out = []
|
||||
for line in lines:
|
||||
if m := PLAYER_LINE.match(line):
|
||||
text = re.sub(r'/127\.0\.0\.1:\d+', '/127.0.0.1:_', m.group(2))
|
||||
text = re.sub(r'entity id \d+ at \(.*\)', 'entity id _ at _', text)
|
||||
out.append(f'[{m.group(1)}] {text}')
|
||||
for a, b in zip(bounds, bounds[1:]):
|
||||
login = []
|
||||
for line in sorted(lines[a:b], key=thread):
|
||||
if m := PLAYER_LINE.match(line):
|
||||
text = re.sub(r'/127\.0\.0\.1:\d+', '/127.0.0.1:_', m.group(2))
|
||||
text = re.sub(r'entity id \d+ at \(.*\)', 'entity id _ at _', text)
|
||||
login.append(f'[{m.group(1)}] {text}')
|
||||
kicks = {l.rsplit('): ', 1)[1] for l in login if '] Disconnecting ' in l}
|
||||
if len(kicks) == 1:
|
||||
login = [l.removesuffix('Disconnected') + next(iter(kicks)) if l.endswith(') lost connection: Disconnected')
|
||||
else l for l in login]
|
||||
out += login
|
||||
return out
|
||||
|
||||
|
||||
@@ -228,16 +249,26 @@ def run_side(label, command, binary, build, services, discovery):
|
||||
os.environ.update(HORIZON_NO_AUDIO='1', HORIZON_API_DISCOVERY=discovery)
|
||||
server = Server(command, directory, f'{label}-online')
|
||||
checks = []
|
||||
starts = []
|
||||
|
||||
def check(name, actual, expected):
|
||||
checks.append(f'{name}: ' + ('ok' if actual == expected else f'expected {expected!r}, got {actual!r}'))
|
||||
|
||||
def login(name, token):
|
||||
"""One client run, over once the server logged the end of its connection: the server notices the
|
||||
closed connection at its next tick, and the next run's lines must not come before that one."""
|
||||
start = len(server.lines)
|
||||
starts.append(start)
|
||||
got = run_client(binary, build, port, f'{label}-{name}', token, discovery)
|
||||
wait_after(server, start, r'lost connection: ')
|
||||
return got
|
||||
|
||||
try:
|
||||
server.wait_for(r'Done \(', timeout=600)
|
||||
services.take_log()
|
||||
|
||||
# 1. Signed in: the full encrypted handshake, then play.
|
||||
got = run_client(binary, build, port, f'{label}-signed-in', GOOD, discovery)
|
||||
got = login('signed-in', GOOD)
|
||||
server.wait_for(r'Tester left the game', timeout=30)
|
||||
log = services.take_log()
|
||||
joins = [e for e in log if e[0] == 'join']
|
||||
@@ -261,20 +292,20 @@ def run_side(label, command, binary, build, services, discovery):
|
||||
|
||||
# 2. A token the session service rejects: vanilla's message, no key sent.
|
||||
mark = len(server.lines)
|
||||
got = run_client(binary, build, port, f'{label}-rejected', BAD, discovery)
|
||||
got = login('rejected', BAD)
|
||||
check('rejected token: client message', got['error'],
|
||||
'disconnected: Failed to log in: Invalid session (Try restarting your game and the launcher)')
|
||||
check('rejected token: no verification', [e[0] for e in services.take_log()], ['join'])
|
||||
|
||||
# 3. The session service forgets the join: the server refuses the player.
|
||||
got = run_client(binary, build, port, f'{label}-forgotten', FORGET, discovery)
|
||||
got = login('forgotten', FORGET)
|
||||
server.wait_for(r'Failed to verify username', timeout=30)
|
||||
check('forgotten join: client message', got['error'], 'disconnected: Failed to verify username!')
|
||||
check('forgotten join: server verified and refused',
|
||||
[(e[0], e[3]) for e in services.take_log()], [('join', True), ('verify', False)])
|
||||
|
||||
# 4. No account: refused by the client before anything is sent to the services.
|
||||
got = run_client(binary, build, port, f'{label}-no-account', None, discovery)
|
||||
got = login('no-account', None)
|
||||
check('no account: client message', got['error'] or '', 'disconnected: This server is in online mode. '
|
||||
'Sign in with your Microsoft account in the Horizon launcher to join it.')
|
||||
check('no account: no service calls', services.take_log(), [])
|
||||
@@ -283,7 +314,7 @@ def run_side(label, command, binary, build, services, discovery):
|
||||
# 5. Verified, but not on the whitelist.
|
||||
server.console('whitelist on')
|
||||
server.wait_for(r'Whitelist is now turned on', timeout=30)
|
||||
got = run_client(binary, build, port, f'{label}-not-whitelisted', GOOD, discovery)
|
||||
got = login('not-whitelisted', GOOD)
|
||||
server.wait_for(r'Disconnecting Tester', timeout=30)
|
||||
check('not whitelisted: client message', got['error'], 'disconnected: You are not white-listed on this server!')
|
||||
check('not whitelisted: verified first', [(e[0], e[3]) for e in services.take_log()],
|
||||
@@ -295,7 +326,7 @@ def run_side(label, command, binary, build, services, discovery):
|
||||
server.console('whitelist add tester')
|
||||
wait_after(server, mark, r'Added Tester to the whitelist')
|
||||
check('whitelist add: known player, no lookup', services.take_log(), [])
|
||||
got = run_client(binary, build, port, f'{label}-whitelisted', GOOD, discovery)
|
||||
got = login('whitelisted', GOOD)
|
||||
wait_after(server, mark, r'Tester left the game')
|
||||
check('whitelisted: connected throughout', got['error'], None)
|
||||
services.take_log()
|
||||
@@ -309,7 +340,7 @@ def run_side(label, command, binary, build, services, discovery):
|
||||
time.sleep(1)
|
||||
finally:
|
||||
server.stop()
|
||||
return checks, player_lines(server.lines)
|
||||
return checks, player_lines(server.lines, starts)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -236,10 +236,18 @@ def run_side(label, scenario, command, directory, client):
|
||||
time.sleep(2)
|
||||
out = directory.parent / f'{label}-{scenario}.txt'
|
||||
client(port, scenario, out)
|
||||
# Carol leaves last: stopping before the server saw her go would log another reason.
|
||||
server.wait_for(r'Carol lost connection', timeout=30)
|
||||
transcript = [f'status enforcesSecureChat {enforces}'] + out.read_text(encoding='utf-8').splitlines()
|
||||
finally:
|
||||
server.stop()
|
||||
return transcript, chat_lines(server.lines)
|
||||
# A player the server disconnects (the transcript has the reason it sent, compared there) is logged as
|
||||
# "lost connection: <that reason>", or as "Disconnected" when vanilla's network thread closed the connection
|
||||
# before its disconnect(reason) ran: a race in vanilla, so the log's reason is left out for them.
|
||||
kicked = {m.group(1) for line in transcript if (m := re.match(r'(\w+) disconnected ', line))}
|
||||
log = [re.sub(r'^(\[\w+\] (\w+) lost connection: ).*', lambda m: m.group(1) + '(the reason sent)'
|
||||
if m.group(2) in kicked else m.group(0), line) for line in chat_lines(server.lines)]
|
||||
return transcript, log
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -17,13 +17,13 @@ stops; Horizon's (`horizon-client --dedicated --nogui`) creates its own from
|
||||
the same server.properties and stops; the `spawn` position of both
|
||||
level.dat files is compared.
|
||||
|
||||
Vanilla is not deterministic everywhere: trees of neighbouring chunks
|
||||
overlap on chunk corners, and which one is placed last depends on the order
|
||||
the server's worker threads decorate the chunks. A spawn column on such a
|
||||
corner can then stand on different leaves from one vanilla run to the next.
|
||||
When the spawns differ, vanilla creates the world up to RETRIES more times;
|
||||
Horizon's spawn passes if one of vanilla's runs gives it too, and the seed is
|
||||
reported as varying in vanilla.
|
||||
Trees of neighbouring chunks overlap on chunk corners, and which one is
|
||||
placed first depends on the order the chunks are decorated. With several
|
||||
worker threads vanilla's order changes from run to run (seed
|
||||
-2080727421395798566 spawned at Y 64, 66 or 72), so its server runs with one
|
||||
(`-Dmax.bg.threads`, Util.maxAllowedExecutorThreads): the chunks are then
|
||||
decorated in its scheduler's order, the one Horizon follows
|
||||
(decoration_order::vanilla_order), and every run gives the same spawn.
|
||||
|
||||
Seeds: the player report's seed (1891623539983467248: Horizon spawned at
|
||||
-48 -48, vanilla at 152 145), the chunk generation harness's seeds and
|
||||
@@ -54,7 +54,6 @@ except ImportError:
|
||||
|
||||
NAME = 'world-spawn-parity'
|
||||
REPORT = 1891623539983467248
|
||||
RETRIES = 3
|
||||
SEEDS = [REPORT, 12345, -7046029254386353131, 987654321987, 4242, -1, 0x5DEECE66D, 20260607, 777, 0]
|
||||
RESOURCES = lab.RESOURCES
|
||||
|
||||
@@ -132,7 +131,8 @@ def main():
|
||||
oh.run_logged(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], build,
|
||||
'compile-rust', timeout=3600)
|
||||
binary = target_dir() / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
|
||||
vanilla_server = ['java', '-Xmx2g', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
|
||||
# One worker thread: the chunks decorated in a fixed order (see above).
|
||||
vanilla_server = ['java', '-Xmx2g', '-Dmax.bg.threads=1', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
|
||||
horizon_server = [binary, '--resources', RESOURCES, '--dedicated', '--nogui']
|
||||
os.environ['HORIZON_NO_AUDIO'] = '1'
|
||||
|
||||
@@ -140,27 +140,20 @@ def main():
|
||||
t0 = time.time()
|
||||
a = world_spawn(vanilla_server, build / f'vanilla-{seed}', seed, f'vanilla-{seed}')
|
||||
b = world_spawn(horizon_server, build / f'horizon-{seed}', seed, f'horizon-{seed}')
|
||||
runs = [a]
|
||||
while b not in runs and len(runs) <= RETRIES:
|
||||
runs.append(world_spawn(vanilla_server, build / f'vanilla-{seed}-{len(runs)}', seed, f'vanilla-{seed}-{len(runs)}'))
|
||||
return seed, runs, b, time.time() - t0
|
||||
return seed, a, b, time.time() - t0
|
||||
|
||||
spawn_diffs, varying = [], []
|
||||
spawn_diffs = []
|
||||
with ThreadPoolExecutor(args.jobs) as pool:
|
||||
for seed, runs, b, took in pool.map(one, all_seeds):
|
||||
ok = b in runs
|
||||
for seed, a, b, took in pool.map(one, all_seeds):
|
||||
ok = a == b
|
||||
if not ok:
|
||||
spawn_diffs.append(seed)
|
||||
elif len(set(runs)) > 1:
|
||||
varying.append(seed)
|
||||
seen = ', '.join(' '.join(map(str, r)) for r in dict.fromkeys(runs))
|
||||
print(f'{"ok " if ok else "DIFF"} {seed}: vanilla {seen} / horizon {b[0]} {b[1]} {b[2]} '
|
||||
print(f'{"ok " if ok else "DIFF"} {seed}: vanilla {a[0]} {a[1]} {a[2]} / horizon {b[0]} {b[1]} {b[2]} '
|
||||
f'(origin chunk {vanilla[seed][2]} {vanilla[seed][3]}; {took:.0f}s)', flush=True)
|
||||
if ok:
|
||||
for path in build.glob(f'*-{seed}*'):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
print(f'World spawns: {len(all_seeds) - len(spawn_diffs)}/{len(all_seeds)} seeds match in {time.time() - started:.0f}s'
|
||||
+ (f'; vanilla itself gave several spawns for {len(varying)}: {varying}' if varying else ''))
|
||||
print(f'World spawns: {len(all_seeds) - len(spawn_diffs)}/{len(all_seeds)} seeds match in {time.time() - started:.0f}s')
|
||||
if origin_diffs or spawn_diffs:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ In part. Horizon runs the full-screen effects of Java shader packs, not their ge
|
||||
|
||||
## Does Horizon collect data?
|
||||
|
||||
Only if you say yes, and each kind has its own switch. Everything is off by default. See [Privacy](/privacy).
|
||||
Only if you say yes, and each kind has its own switch. Everything is off by default. The one thing sent without asking is an anonymous "I'm playing" ping, for the count of how many play right now: it holds a random number made each time the game starts, the version, your OS and whether a world is open to LAN, and nothing about you. See [Privacy](/privacy).
|
||||
|
||||
## How often does it update?
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ This works today, on a VPS or any computer. Everything below is the same program
|
||||
|
||||
### 1. Install
|
||||
|
||||
On Linux (x86-64) and macOS (Apple Silicon):
|
||||
On Linux (x86-64) and macOS (Apple Silicon; Intel from the next release):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user