Migrate player integration coverage to server-thread hosting

Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
This commit is contained in:
rdbtCVSandOpenAI Codex GPT-6.1 committed 2026-10-25 19:36:11 +00:00
1 parent 8177a6f450
commit bd09f8b03e
29 files changed
+932 -100

No files matched your search

+4 -4
View File
@@ -66,7 +66,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
@@ -156,7 +156,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
@@ -164,8 +164,8 @@ when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
@@ -610,12 +610,17 @@ mod tests {
#[test]
fn unreachable_games_fail_with_status_3() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
let home =
std::env::temp_dir().join(format!("horizon-ctl-unreachable-{}", std::process::id()));
std::fs::create_dir_all(&home).unwrap();
let mut s = Session::new(home.join("nobody.sock"), None);
s.home = home.clone();
let f = s.run(&["state".to_owned()]).unwrap_err();
assert_eq!(f.status, UNREACHABLE);
assert_eq!(f.reply["kind"], "connection");
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
assert_eq!(f.status, USAGE);
std::fs::remove_dir_all(home).unwrap();
}
#[test]
+3
View File
@@ -4073,6 +4073,9 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
}
fn tick(&mut self) -> Result<()> {
if self.agent.hold_server {
return Ok(());
}
self.tick_debug_keys()?;
self.tick_debug_charts();
if self.remote.is_some() {
+3
View File
@@ -300,6 +300,8 @@ struct Glide {
pub(super) struct Agent {
task: Option<Task>,
glide: Option<Glide>,
/// Control-fixture barrier: hold simulation and packet handling, not transport threads.
pub hold_server: bool,
}
pub(super) enum Handled {
@@ -662,6 +664,7 @@ impl Game<'_> {
});
if let Some(chat) = &self.ui.chat_screen {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() {
v["menu"] = self.title_value();
+57
View File
@@ -11,6 +11,9 @@ use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode};
const HELP: &[&str] = &[
"status game, player, streaming and renderer state",
"simulation authoritative projectiles in the owner's level",
"server-hold on|off hold simulation and player packets at a control-fixture barrier",
"stall MILLISECONDS stall the window thread (0..5000), leaving transport alive",
"key KEY down|up|tap|repeat press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
"release release every key and mouse button",
"mouse left|right|middle down|up|click",
@@ -238,6 +241,47 @@ impl Game<'_> {
}
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"status" => Ok(Some(self.control_status())),
"stall" => {
let millis: u64 = arg(1)?.parse()?;
if millis > 5000 {
return Err("stall is limited to 5000 milliseconds".into());
}
std::thread::sleep(Duration::from_millis(millis));
ok()
}
"simulation" => {
self.require_world()?;
let value = if let Some(server) = &self.integrated {
server
.call(|g| {
g.in_owner_level(|g| {
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
})
})
.ok_or("the integrated server stopped")?
} else {
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
};
Ok(Some(value))
}
"server-hold" => {
self.require_world()?;
let held = match arg(1)? {
"on" => true,
"off" => false,
_ => return Err("server-hold on|off".into()),
};
if let Some(server) = &self.integrated {
server
.call(move |g| g.agent.hold_server = held)
.ok_or("the integrated server stopped")?;
} else if self.remote.is_some() {
return Err("server-hold requires the world's host".into());
} else {
self.agent.hold_server = held;
}
ok()
}
"pad" => Ok(Some(self.pad_command(&words)?)),
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
@@ -496,10 +540,23 @@ impl Game<'_> {
"mobs" => {
self.require_world()?;
let radius: f64 = words.get(1).map_or(Ok(64.0), |r| r.parse())?;
if let Some(server) = &self.integrated {
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let value = server
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
.ok_or("the integrated server stopped")?;
return Ok(Some(value));
}
Ok(Some(self.control_mobs(radius)))
}
"hurtlog" => {
self.require_world()?;
if let Some(server) = &self.integrated {
let value = server
.call(|g| g.control_hurt_log())
.ok_or("the integrated server stopped")?;
return Ok(Some(value));
}
Ok(Some(self.control_hurt_log()))
}
"resume" => {
@@ -316,6 +316,9 @@ impl Game<'_> {
/// The `mobs` command: nearby mobs with position, health and running goals.
pub(super) fn control_mobs(&self, radius: f64) -> serde_json::Value {
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
self.control_mobs_at(radius, center)
}
pub(super) fn control_mobs_at(&self, radius: f64, center: Vec3) -> serde_json::Value {
let list: Vec<serde_json::Value> = self
.mobs
.iter()
@@ -360,6 +360,12 @@ impl Game<'_> {
let mut next = Instant::now();
while !flags.stop.load(Ordering::Relaxed) {
self.run_tasks();
if self.agent.hold_server {
// Keep servicing control requests so the fixture can release the barrier.
// Unlike /tick freeze, no player's queued packets are applied here.
self.wait_for_next_server_tick(&mut next, true);
continue;
}
let paused = flags.paused.load(Ordering::Relaxed);
if paused {
// Only the connections run.
@@ -272,7 +272,8 @@ impl Game<'_> {
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)}));
}
for p in &self.creatures.projectiles {
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position)}));
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity),
"player_owned": p.owner == Some(horizon_client::mobs::Who::Player)}));
}
for (kind, p, _) in self.potions.thrown_entities() {
out.push(json!({"kind": kind, "position": at(p)}));
+1 -1
View File
@@ -51,7 +51,7 @@ file selects ~103.
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs`, `L/friends_screen.rs`, `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
| 36 | Audio and music (38) | `L/audio.rs`, `L/sound_engine.rs`, `L/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e --server-thread` (portals, levels, distance, lava) |
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
| 38 | Command parsing (34) | `L/command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`) | command_parse (corpus `tools/command_parse_corpus.py`) |
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `tools/perf_bench.py`, `L/telemetry.rs`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
| 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet |
+46
View File
@@ -77,3 +77,49 @@ On ludodesktop, source `~/code/horizon-parity/env.sh` first: it sets the ALSA bu
## Reading vanilla
Besides the Vineflower sources in `.local/minecraft/<version>/sources/`, `tools/rea_vanilla.py` queries the shipped bytecode through REA (callers through lambdas, overloads, a second decompiler); see [decompilation status](decompilation-status.md#querying-the-shipped-bytecode-with-rea).
## Player-facing integration and deterministic fixtures
`python3 tools/lan_e2e.py` runs every LAN check with an integrated server thread,
the same path as normal single player. `python3 tools/test_friends.py --client
target/fast/horizon-client --relay target/fast/horizon-relay --baseline` covers
plain LAN, direct invitations, a local relay and ended invitations in that mode.
Both accept `--in-frame` for comparison and retain `--server-thread` for existing
callers. Run Linux games under `xvfb-run -a` when no display is available. Each
process gets scratch game, launcher, XDG data and config homes; telemetry, audio
and launcher self-update are disabled.
`lan_integration` and `friends_integration` register these suites with the normal
`parity.py` selection used by CI. Their traced imports and Cargo artifacts select
them when the drivers or game implementation change. LAN writes `results.json`
with the host mode and each result beside its screenshots and logs. A skipped
case is recorded explicitly and does not count as passing coverage.
The game-binary fixture audit keeps these complementary direct rule tests:
- `command_harness.rs`: command feedback, styled spans and result callbacks,
held player position, explicit ticks and portal completion matching
`VanillaCommandExecOracle`. LAN `commands`, `feedback`, `styles`, `levels` and
`portals` cover command input, packet feedback and travel across the boundary.
- `dispense_fixture.rs`: seeded synthetic worlds, block/event traces, item
use/mining and combat hooks matching `VanillaDispenserOracle`. These isolate
rule results and RNG order; LAN machines, crafting, clicks, fireball, mining
and combat exercise real input and server handling separately. The new
`dispenser_input_host` / `dispenser_input_guest` checks press a button and
verify authoritative inventory consumption and exactly one drop on both clients.
- `sculk_fixture.rs`: scripted events and exact per-tick sculk/warden traces
matching `VanillaSculkOracle`; introducing wall-clock network ticks would
destroy that deterministic oracle contract. `sculk_input_host` and
`sculk_input_guest` separately exercise real footsteps reaching a sensor;
failures remain visible rather than changing the oracle trace.
- `net_harness.rs`: already a real network client of a dedicated server; keep
its handshake, world loading, chat and command packet assertions.
Library probes, `lan_bot`'s protocol hub fixture, generation/save tests and
streaming/mesh tests remain fast deterministic coverage. They do not become
full game launches. Setup and authoritative reads use the owning server;
interactions stay on the client. `server-hold on|off` provides a prediction-test
barrier between server ticks, including player packet handling, while transport
threads and control calls remain responsive. `stall MILLISECONDS` stalls only
the window to test recovery from a hung frame without process signals interrupting
socket reads. Neither mechanism changes normal gameplay.
+297
View File
@@ -0,0 +1,297 @@
# Server-thread integration coverage migration: validation ledger
Tested on 2026-10-24, Linux / NVIDIA Vulkan, with Java 25 and the pinned 26.3
resources and jars. Base: `e423e570c`. Player integration uses real Horizon
clients under Xvfb and real control sockets. The migrated game drivers use fresh
temporary game, launcher and XDG data/config homes; telemetry, audio and launcher
self-update are disabled. No player data or prepared resources are committed.
An interrupted historical-world generator initially inherited the harness
environment instead of those per-game homes. It wrote only to ignored `.local`
fixture folders; it was stopped, and subsequent validation uses the already
prepared historical worlds. That setup mistake is not counted as a passing run.
## Before editing
Open PR searches for server-thread, integration, LAN and friends work found no
overlapping migration. The full original LAN suite was started with
`--server-thread` against a build of the unchanged base before repository edits.
| Run | Hosting | Result |
| --- | --- | --- |
| Original full LAN attempt | Server thread | 58 checks reported: 39 passed, 18 failed, deflection skipped; the runner then aborted at `light` with an uncaught `TypeError`. |
| Original remaining 45 checks | Server thread | 31 passed, 14 failed, no skips. Used the unchanged loaded driver and original executable. |
| Friends, including `--baseline` | Server thread | Plain LAN, direct invitation, local relay and ended-invite rejection passed. |
Together the two LAN invocations attempted all 103 original checks. The initial
`sign` case left its editor open, breaking the later death-screen assertion.
`predict` suspended the whole host process; resuming it interrupted a socket
read (`Interrupted system call`), disconnected the guest and caused later
session failures. The remaining run also exposed sign placement outside reach
and cascading dead-player failures. These are recorded failures, not passes.
## Infrastructure changes validated during migration
- Close the sign editor after the original placement check. Give the two-player
sign case a reachable arena and wait for placement data/feedback before typing.
Observe the submitted text on both clients within the existing assertion budget.
The revised sign case passed three consecutive server-thread reruns.
- Release held input, close screens and respawn dead players before independent
cases. Keep every case's death, menu and held-action assertions inside the case.
- Replace process signals with a server barrier for prediction and an intentional
window-thread stall for frame-recovery tests. Prediction still tests immediate
client prediction, authoritative agreement, iron-door rejection and exactly-once
sound; it also asserts that the server clock did not advance at the barrier.
- Route mob/projectile/hurt-log inspection to the owning server. Deflection runs
in the default suite rather than reporting an in-frame-only skip as a pass.
- Record skipped checks explicitly as uncovered, and record ordinary case errors
without aborting the rest of the suite. Write mode and results to `results.json`.
- Serialize the two CI integration entry points on the shared GPU. An earlier
concurrent comparison failed to start a graphics device because of GPU memory
exhaustion; it was rerun serially, without changing a gameplay timeout.
## Post-migration runs
The first complete server-thread migration run, before the case-cleanup fixes,
ran all 103 original cases: 88 passed and 15 failed. Failures were `deflect`,
`light`, `swings`, `cracks`, `effects`, `portals`, `flight`, `boats`, `minecarts`,
`frames`, `hands`, `riding`, `mounts`, `contacts` and `absorption`. Many followed
the host's deflection damage/death. They are not used as evidence of a clean suite.
The next complete default server-thread run executed all 107 cases (the original
103 plus four separate host/guest sensor and dispenser input cases): **97 passed,
10 failed, zero skipped**. Failures were `commands`, `deflect`, `swings`, `flight`,
`boats`, `minecarts`, `keepinv`, `fishing`, `sculk_input_host` and
`sculk_input_guest`. Lighting passed this run; earlier lighting failures are not
claimed as a persistent blocker.
A subsequent input-timing correction runs the existing double-jump key/wait
sequence through one `horizon-ctl script` process, preserving the seven-tick
flight window. A targeted server-thread rerun passed flight for both players,
swings, boats and minecarts. Command completion still failed: the observable
suggestion list did not appear within the existing two-tick budget. The in-frame
rerun passed flight and failed completion in the same way. Passing isolated
swings/vehicle cases does not clear the unexplained failures in the full sequence.
The full-run inventory retention and fishing failures also remain unresolved.
Friends passed plain LAN, direct invitation, local relay and ended-invite checks
both after making server-thread hosting the default and with explicit `--in-frame`.
Targeted in-frame comparisons passed chest, sign placement, prediction, clock
recovery, lighting and deflection. The revised two-player sign case and both
new dispenser input cases passed too. Command completion failed twice: Tab after
`/gamem` left `/gamem` unchanged. The host's sculk input passed; the guest's failed.
Targeted server-thread checks passed prediction and both new dispenser input
cases. Dispensing checks authoritative inventory consumption and exactly one
matching item on each client. Both sensor cases confirmed movement on the client
and server, but the sensor stayed inactive. Deflection still failed the original
no-fireball-damage assertion after allowing the click through a normal client
input tick. An experimental frozen-world synchronization approach also failed
and is not part of the final implementation.
## Deterministic rule and source checks
`python3 tools/parity.py command_exec dispenser sculk --timeout 1500 --jobs 2`:
| Direct fixture | Result |
| --- | --- |
| Command execution | 781/781 command/callback records match the vanilla oracle. |
| Dispenser and related rule calls | 6000 cases match; no difference groups. |
| Sculk | 435 scenarios match every compared tick. |
All three harnesses passed. Their seeded rule calls and controlled ticks remain
direct; they do not claim client/server boundary coverage. The existing
`net_harness.rs` remains a real dedicated-server client fixture.
The first quick self-check passed 289 Python tests, rustfmt and Clippy. Rust tests
reported 1081 passed, one failed and 26 ignored: the shared prepared resource
folder lacked `registries.json`. The pinned local datagen report was copied into
a private ignored resource copy; the failed
`mod_content::tests::content_root_loads_with_every_table` test then passed.
The focused environment/parity-runner/self-check Python tests passed (38 tests).
The full `python3 tools/pr_ready.py --base origin/main` completed: 1205 Rust tests
and 291 Python tests passed, as did rustfmt, Clippy and documentation checks.
Its parity phase failed after the historical-world setup interruption described
above, so the overall self-check is **not green**. A subsequent `parity.py --changed origin/main --timeout 1500` selected 160
harnesses, 151 requiring runs. Three completed and passed (`firework_tooltip`,
`held_drop`, `interaction_input`). Both integration wrappers exceeded that
invocation's 25-minute per-harness deadline: friends was waiting for the shared
GPU lock, and LAN was still executing cases. Automatic retries were stopped and
the detached game processes cleaned up. The remaining affected harnesses did not
complete. This is incomplete validation, not a passing corrected parity gate;
compilation is not a substitute for the gameplay failures above.
The final source self-check, `python3 tools/pr_ready.py --quick`, passed 1205 Rust
tests, 291 Python tests, rustfmt, Clippy and documentation checks. After correcting
the integration build arguments to name each binary (so traced selection includes
`game.rs` and `game/integrated.rs`), the named client/control/relay build and 38
focused Python tests passed. Dependency extraction from the real Cargo dep-info
confirmed both game files are selected.
## Limitations recorded before the follow-up fixes
These were the remaining blockers when this ledger was first written; the
completion runs below record their status after the follow-up fixes.
- Server-player footsteps do not reach sculk sensors. `tick_entity_events` in
`game/sculk.rs` emits player motion events from `Game.player`; the integrated
server's players live in its guest hub. The in-frame host passes and its guest
fails too, separating this shared server-player gap from the deterministic rule
oracle. A fix needs per-player event identity/state rather than weakening the test.
- The server-thread host takes fireball damage in deflection; the equivalent
in-frame check passes. Client-visible and authoritative projectile state diverge
during the synchronization investigation. No gameplay fix is included.
- Command completion fails in both modes as described above; it is not reported
as a passing fallback comparison.
- Full-sequence swings, vehicle, inventory-retention and fishing failures need
further fixture/state investigation. Swings and both vehicles passed isolated
reruns; that evidence does not establish a gameplay defect or a full-suite pass.
No failing case is skipped, expectation relaxed, or existing timeout increased to
hide these results. At that stage the PR stayed draft with these coverage blockers.
## Combined validation before the gameplay split (2026-10-25)
Merged `main` at `bd591f985` and resolved the overlapping motion, projectile and
test-driver changes. The shared projectile inertia helper retains main's custom
acceleration power, with a regression test. Integration environments now call
`lab.game_env`; the friends entry point delegates its actual launches to that
isolated driver. The dedicated-server process keeps one environment argument,
and the control-tool connection test uses a scratch home rather than reading
the player's No AI setting.
The first complete run after that merge executed **119 cases: 114 passed,
five failed, zero skipped**. Failures were `swings`, `flight`, `boats`,
`minecarts` and `keepinv`. All the additional regression cases from main passed.
Command completion, deflection, fishing and both sensor/dispenser input roles
also passed in that full sequence.
The failures exposed fixture setup, input cadence and an off-hand clearing bug:
- Aim at the hoe target's top face, observe that target, then send a normal
mouse click. Keep the exact one-swing/farmland assertion. A subsequent full
repeat passed flight and both vehicle checks but still failed the hoe case;
it was stopped after identifying the root cause rather than counted as green.
The earlier explosion case leaves an off-hand shield, and `/clear` omitted
that slot. Vanilla correctly blocks hoe use with that shield. Follow
`Inventory.clearOrCountMatchingItems`, `getContainerSize`, `getItem` and its
equipment-slot mapping: clear off-hand items and visit armor feet to head.
New oracle cases cover off-hand counting, partial/full removal and limited
armor-slot ordering. The explosion-then-swings sequence passes after this fix.
- Keep the original double-jump key sequence and seven-tick flight rule; run
this tick-sensitive case at four ticks per second so IPC scheduling on a busy
machine cannot stretch the input past that window. Restore 20 TPS afterward.
- Deliver the original ten vehicle punches and two-tick waits over one control
connection. Separate process launches had stretched the gaps to ten ticks,
letting the vehicle's damage decay before the next hit.
- Protect inactive inventory-test players after the low-health totem case,
assert that the active player is alive before the intended death, and provide
a full floor under scattered death drops. A targeted retry with the old small
platform lost a helmet over its edge; both players' exact drop/retention
assertions passed with the larger floor.
Targeted server-thread reruns passed all five corrected cases, plus totems,
fishing and both sensor input roles. Friends passed plain LAN, direct invites,
the local relay and ended-invite rejection. The six focused parity harnesses
passed: `command_exec`, `dispenser`, `sculk`, `dedicated_server`,
`server_entities` and `server_player_damage`.
The normal-runner full repeat also exposed an XP observation race: the guest
had already collected all 21 points before the two-tick in-flight snapshot
(`seen=0`, with the correct XP gain and no remaining orbs). Freeze spawning,
step two world ticks, observe the tracked orbs, then unfreeze for the original
pickup assertions. The targeted check passed with three visible orbs and
exactly 21 points; the full runner restarted with that correction.
That repeat passed the former XP, swings, flight, boat and minecart failures,
but its level-ticking subcase found a wandering pig on a neighboring block at
Y=66 instead of the landing floor at Y=65. The armor stand landed correctly.
This subcase measures falling and simultaneous level ticking. Each level now
gets a full clear landing/walking area, retaining the pigs' normal AI. A
hazard-then-levels rerun confirmed that both pigs and armor stands land at the
expected heights with distinct entity IDs across levels.
The broader parity invocation completed **116/117 runs passed**. The remaining
`tab_list` comparison matched 998/1000 lists and all 400 skin images. Both
list mismatches contained `Ⅻ`: the oracle's bitmap-only font setup used the
missing glyph instead of vanilla's Unicode fallback. Load the full default font
reference chain from the prepared assets with vanilla's provider definitions
and default option filters. Prefetch the fake skin lookups together before the
unchanged one-second observation window, retaining deliberately pending cases.
A fresh rerun passed **1000/1000 lists, 3510 frames and 400/400 skin images**,
with zero oracle errors. Fresh secure-chat and dimension-player parity reruns
passed after the command fix as well. The extended command oracle matched all
**843 command/callback records**.
Rechecking the shared font helper passed eight of nine additional harnesses;
`pack_screen` matched 2778/3000 cases with its old bitmap-only oracle. Its
newline/control glyph metrics also require the Unicode fallback (the newline
advance is 9 with the full font, 6 with the bitmap-only setup). Give this oracle
the same full default font and prepared asset root. A fresh rerun matched
**3000/3000 pack screens**, with zero oracle errors and unchanged comparisons.
The source self-check passed **1237 Rust tests and 270 Python tests**, rustfmt,
Clippy and doc tests. The private resource copy contains both pinned reports
and a regular `facts.json`, as required by the resource-isolation regression.
Prepared historical test worlds were copied into the ignored fixture cache
where available; they are not player saves or committed resources.
The final in-frame comparison passed **14/14 cases, zero skipped**: commands,
deflection, both sensor roles, flight, swings, boats, minecarts, chest, prediction,
signs, both dispenser roles and inventory retention. A separate in-frame XP
comparison passed with three visible orbs and exactly 21 points. Friends and
the dedicated-server harness passed through the normal parity runner.
The final normal-runner server-thread LAN repeat passed **119/119 cases,
zero skipped**, in 1940 seconds. This full sequence includes all earlier failure
cases, both sensor/dispenser roles and the additional regressions from main.
The final full `python3 tools/pr_ready.py` gate passed against merge base
`bd591f985`: **1237 Rust tests, 270 Python tests**, rustfmt, Clippy, public-tree
checks and documentation tests. Its parity stage selected **118 affected
harnesses**; 117 had current content-matched passing records and the remaining
harness passed during the gate. No failed or skipped harness remained.
The gate reported the existing scope warning (35 files, 584 lines of code);
the combined migration exceeded the suggested 500-line size. At that point
the combined PR was marked ready for review. The split below supersedes that
readiness status and the earlier description block.
## Gameplay fixes split into independent PRs (2026-10-25)
At the user's request, the three gameplay fixes were removed from this PR's
net diff and opened independently against `main`:
- [#1903](https://github.com/ludovic111/horizon/pull/1903): `/clear` off-hand
handling and vanilla inventory-slot order, including the added command-oracle
cases. The independent branch matches **843/843 command/callback records**.
- [#1904](https://github.com/ludovic111/horizon/pull/1904): connected-client
fireball motion and authoritative corrections, including the acceleration
regression and a focused `remote_fireball` integration harness. The new
harness fails on unchanged main in both hosting modes and passes with only
that gameplay fix applied.
- [#1905](https://github.com/ludovic111/horizon/pull/1905): per-player sculk
emission and history, including a focused `server_player_sculk` integration
harness. Without the fix, both server-thread players and the in-frame guest
fail while the in-frame host passes. With only this fix, **4/4 cases** pass,
along with **435/435 vanilla scenarios matching on every tick**.
Each independent branch passes the quick source self-check using
`lab.game_env()` scratch homes (287 Python tests; 1241 Rust tests for clear
and sculk, 1242 for fireballs; formatting, Clippy and doc tests). Their broader
parity selections are listed for CI, rather than claimed as standalone full
parity passes.
The migration was merged with main at `8177a6f45` before removing the fixes.
Its test migration, fixture synchronization, isolated environments, control
inspection and font-oracle corrections remain. The **119/119 full LAN pass**
and full affected-parity readiness result above belong to the combined
pre-extraction snapshot `cc9410b6`; they do not establish that the migration
alone passes after the gameplay code has been removed. This PR stays draft
until the three dependencies land and its full integration suite is rerun.
The post-split migration quick source gate passes **1241 Rust tests and 290
Python tests**, formatting, Clippy, public-tree and doc checks. It lists **70
affected parity harnesses** for CI. Full post-split integration validation is
pending the three gameplay dependencies; no replacement full-suite pass is
claimed here.
@@ -10,6 +10,8 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.HashSet;
import java.util.Set;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GlyphSource;
import net.minecraft.client.gui.font.glyphs.BakedGlyph;
@@ -31,9 +33,13 @@ import net.minecraft.util.RandomSource;
*/
public final class ParityFont {
public static ResourceManager resources() throws Exception {
return resources(Path.of("/nonexistent-horizon-assets"));
}
public static ResourceManager resources(Path assets) throws Exception {
var method = net.minecraft.client.resources.ClientPackSource.class.getDeclaredMethod("createVanillaPackSource", Path.class);
method.setAccessible(true);
var pack = (net.minecraft.server.packs.VanillaPackResources) method.invoke(null, Path.of("/nonexistent-horizon-assets"));
var pack = (net.minecraft.server.packs.VanillaPackResources) method.invoke(null, assets);
return pack.asResourceManager();
}
@@ -50,6 +56,38 @@ public final class ParityFont {
}
}
}
return create(providers);
}
/** The full default font, including references and the external Unihex fallback. */
public static Font createDefault(ResourceManager resources) throws Exception {
List<GlyphProvider> providers = new ArrayList<>();
loadProviders(resources, Identifier.withDefaultNamespace("default"), providers, new HashSet<>());
return create(providers);
}
private static void loadProviders(ResourceManager resources, Identifier font, List<GlyphProvider> providers,
Set<Identifier> visiting) throws Exception {
if (!visiting.add(font)) throw new IllegalArgumentException("Cyclic font reference: " + font);
var file = font.withPath("font/" + font.getPath() + ".json");
try (var reader = new InputStreamReader(resources.open(file), StandardCharsets.UTF_8)) {
var json = JsonParser.parseReader(reader).getAsJsonObject();
for (var element : json.getAsJsonArray("providers")) {
var conditional = GlyphProviderDefinition.Conditional.CODEC.parse(JsonOps.INSTANCE, element).getOrThrow();
if (!conditional.filter().apply(Set.of())) continue;
var definition = conditional.definition().unpack();
if (definition.left().isPresent()) {
providers.add(definition.left().orElseThrow().load(resources));
} else {
loadProviders(resources, definition.right().orElseThrow().id(), providers, visiting);
}
}
} finally {
visiting.remove(font);
}
}
private static Font create(List<GlyphProvider> providers) {
GlyphSource source = new GlyphSource() {
@Override
public BakedGlyph getGlyph(int codepoint) {
@@ -370,7 +370,7 @@ public final class VanillaPackScreenOracle {
unsafeField.setAccessible(true);
unsafe = unsafeField.get(null);
allocate = unsafe.getClass().getMethod("allocateInstance", Class.class);
font = ParityFont.create(ParityFont.resources());
font = ParityFont.createDefault(ParityFont.resources(Path.of(args[2])));
Minecraft mc = alloc(Minecraft.class);
put(mc, Minecraft.class, "font", font);
put(mc, Minecraft.class, "lastInputType", InputType.MOUSE);
@@ -230,7 +230,7 @@ public final class VanillaTabListOracle {
unsafeField.setAccessible(true);
unsafe = unsafeField.get(null);
allocate = unsafe.getClass().getMethod("allocateInstance", Class.class);
Font font = ParityFont.create(ParityFont.resources());
Font font = ParityFont.createDefault(ParityFont.resources(Path.of(args[2])));
var gson = new Gson();
var out = new StringBuilder();
for (String line : Files.readAllLines(Path.of(args[0]))) {
@@ -284,6 +284,8 @@ public final class VanillaTabListOracle {
put(mc, Minecraft.class, "user", user);
var skins = skinManager(c);
put(mc, Minecraft.class, "skinManager", skins);
var profiles = new ArrayList<GameProfile>();
var lookups = new ArrayList<CompletableFuture<?>>();
for (var p : c.getAsJsonArray("players")) {
var o = p.getAsJsonObject();
var uuid = UUID.fromString(o.get("uuid").getAsString());
@@ -296,12 +298,22 @@ public final class VanillaTabListOracle {
: new Property("textures", t.get("value").getAsString(), sig.getAsString()));
}
var profile = new GameProfile(uuid, o.get("name").getAsString(), new PropertyMap(properties));
// Let the lookup's unpacking and downloads run before the first frame,
// as they would have by the time a player list shows.
try {
skins.get(profile).get(1, java.util.concurrent.TimeUnit.SECONDS);
} catch (java.util.concurrent.TimeoutException pending) {
}
profiles.add(profile);
lookups.add(skins.get(profile));
}
// Start every lookup before waiting: intentionally pending fake
// downloads must not add a second for each player in the list.
// Keep the same observation window and leave those futures pending.
try {
CompletableFuture.allOf(lookups.toArray(CompletableFuture[]::new))
.get(1, java.util.concurrent.TimeUnit.SECONDS);
} catch (java.util.concurrent.TimeoutException pending) {
}
int profileIndex = 0;
for (var p : c.getAsJsonArray("players")) {
var o = p.getAsJsonObject();
var profile = profiles.get(profileIndex++);
var uuid = profile.id();
var info = new PlayerInfo(profile, false);
put(info, PlayerInfo.class, "gameMode", GameType.byId(o.get("gameMode").getAsInt()));
put(info, PlayerInfo.class, "latency", o.get("latency").getAsInt());
+1
View File
@@ -29,6 +29,7 @@ EXEMPT = {
"build_release.py": "builds and packs the release",
"release_train.py": "release bookkeeping",
"pr_ready.py": "runs cargo checks and parity.py",
"test_friends_integration_parity.py": "builds binaries; test_friends.Game isolates every game through integration_env",
"port_harness.py": "ports Java code, starts no game",
}
+50
View File
@@ -0,0 +1,50 @@
"""Automated game runs must never inherit a real player's data directories."""
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from tools.integration_env import game_environment, run_game, require_server_reports
class IntegrationEnvironmentTests(unittest.TestCase):
def test_all_data_homes_and_network_settings_are_overridden(self):
keys = ('HORIZON_HOME', 'HORIZON_LAUNCHER_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME')
with tempfile.TemporaryDirectory() as root:
env = game_environment({**dict.fromkeys(keys, '/real/player'), 'DISPLAY': ':123'}, root)
homes = [Path(env[key]) for key in keys]
self.assertEqual(len(set(homes)), 4)
self.assertTrue(all(p.is_dir() and p.is_relative_to(root) for p in homes))
self.assertEqual(env['DISPLAY'], ':123')
self.assertEqual(env['HORIZON_TELEMETRY_URL'], 'off')
self.assertEqual(env['HORIZON_LAUNCHER_NO_SELF_UPDATE'], '1')
self.assertEqual(env['HORIZON_NO_AUDIO'], '1')
def test_each_fixture_process_gets_fresh_homes_and_cleanup(self):
script = ('import json, os; from pathlib import Path; '
'homes = [os.environ[k] for k in '
'("HORIZON_HOME", "HORIZON_LAUNCHER_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME")]; '
'assert all(Path(p).is_dir() for p in homes); print(json.dumps(homes))')
runs = [json.loads(run_game([sys.executable, '-c', script], env=os.environ,
capture_output=True, text=True, check=True).stdout) for _ in range(2)]
self.assertFalse(set(runs[0]) & set(runs[1]))
self.assertTrue(all(not Path(p).exists() for homes in runs for p in homes))
class PreparedReportTests(unittest.TestCase):
def test_partial_report_install_shadows_complete_fallback(self):
with tempfile.TemporaryDirectory() as root:
resources = Path(root) / 'horizon-client' / '26.3'
fallback = Path(root) / 'datagen' / 'out' / 'reports'
resources.mkdir(parents=True)
fallback.mkdir(parents=True)
for name in ('registries.json', 'commands.json'):
(fallback / name).write_text('{}')
self.assertEqual(require_server_reports(resources).resolve(), fallback)
(resources / 'registries.json').write_text('{}')
with self.assertRaisesRegex(ValueError, 'installed together'):
require_server_reports(resources)
(resources / 'commands.json').write_text('{}')
self.assertEqual(require_server_reports(resources), resources)
+2
View File
@@ -75,6 +75,8 @@ def game_env(screen, home=None):
extra['HORIZON_HOME'] = home
if sys.platform == 'darwin':
return lab.game_env(**extra)
if not shutil.which('vscreen') and os.environ.get('DISPLAY'):
return lab.game_env(**extra)
env = lab.game_env(VSCREEN=screen, **extra)
subprocess.run(['vscreen', 'up'], env=env, check=False, capture_output=True)
text = subprocess.run(['vscreen', 'env'], env=env, capture_output=True, text=True, check=True).stdout
+54
View File
@@ -0,0 +1,54 @@
"""Per-process scratch data for game integration tests (never a player's home)."""
from pathlib import Path
from contextlib import contextmanager
import os
import subprocess
import tempfile
try:
from . import lab
except ImportError:
import lab
def game_environment(base, root):
root = Path(root)
env = dict(base, HORIZON_TELEMETRY_URL='off')
for key, folder in [('HORIZON_HOME', 'home'), ('HORIZON_LAUNCHER_HOME', 'launcher'),
('XDG_DATA_HOME', 'data'), ('XDG_CONFIG_HOME', 'config')]:
path = root / folder
path.mkdir(parents=True, exist_ok=True)
env[key] = str(path)
return lab.game_env(env)
def run_game(command, **kwargs):
"""Run a headless game fixture with the same isolation as real-client tests."""
with tempfile.TemporaryDirectory(prefix='hz-fixture-') as root:
kwargs['env'] = game_environment(kwargs.pop('env', os.environ), root)
if any(key in kwargs['env'] for key in
('HORIZON_COMMAND_SCRIPT', 'HORIZON_DISPENSE_FIXTURE', 'HORIZON_SCULK_FIXTURE')):
# These compare seeded rule/callback traces, not the client/server boundary.
command = [*command, '--in-frame']
return subprocess.run(command, **kwargs)
@contextmanager
def suite_lock():
"""Real-client CI suites share a GPU, even when parity.py runs harnesses in parallel."""
import fcntl
with (Path(tempfile.gettempdir()) / 'horizon-integration-gpu.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def require_server_reports(resources):
"""Match Builtin.find: a registry report shadows later fallback directories."""
resources = Path(resources)
reports = next((p for p in (resources, resources / '../../datagen/out/reports',
resources / '../datagen/out/reports')
if (p / 'registries.json').is_file()), None)
if reports is None or not (reports / 'commands.json').is_file():
raise ValueError(f'incomplete prepared resources in {resources}: registries.json and '
'commands.json must be installed together; prepare the pinned resources again')
return reports
+237 -59
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""LAN multiplayer end to end: a host opens its world to LAN and a guest joins, both real games.
Two Horizon clients run side by side under horizon-ctl, each in its own scratch folder. The host
The host uses its integrated server thread by default; --in-frame compares the fallback, and
--server-thread remains accepted. Two Horizon clients run under horizon-ctl in scratch folders. The host
creates a survival world and opens it to LAN with commands allowed; the guest connects to it by
address. The script then checks, from both sides, what a guest's game used to get wrong (#366):
@@ -268,7 +269,6 @@ import math
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
@@ -280,11 +280,12 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from agent_control_e2e import Cli, Failed, game_env # noqa: E402
import lab # noqa: E402
from integration_env import game_environment, require_server_reports # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
# The host plays single player on its server thread (--server-thread).
SERVER_THREAD = False
# Match normal single player; --in-frame is the comparison path.
SERVER_THREAD = True
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
@@ -298,6 +299,7 @@ class Game:
os.symlink(resources, self.work / '.local/horizon-client' / lab.VERSION)
self.socket = self.work / 'c.sock'
env = game_env(os.environ.get('VSCREEN', 'lan-e2e'), home=self.work / 'home')
env = game_environment(env, self.work)
self.d = Cli(ctl, self.socket, self.work, env)
self.d.name = name
@@ -323,6 +325,28 @@ class Game:
def wait(self, ticks):
self.d('wait', ticks=ticks)
def script(self, lines):
"""Keep tick-sensitive input in one ctl process, through the normal control commands."""
result = subprocess.run(self.d.base + ['script'], input='\n'.join(lines) + '\n',
cwd=self.work, env=self.d.env, capture_output=True, text=True,
timeout=600)
replies = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
ticks = [r['ticks'] for r in replies if 'ticks' in r]
print(f'[{self.name}] script {lines} -> {replies[-1] if replies else result.stderr[:400]}; ticks={ticks}')
assert result.returncode == 0 and replies and not any('error' in r for r in replies), result.stdout
return replies[-1]
def until(self, observe, predicate, ticks=100):
"""Wait for an observable answer, within the existing tick budget."""
last = observe()
for _ in range(ticks):
if predicate(last):
return last
self.wait(1)
last = observe()
assert predicate(last), f'{self.name}: state did not arrive within {ticks} ticks: {last}'
return last
def close(self):
try:
self.d('quit')
@@ -530,6 +554,8 @@ def check_sign(host, guest, out):
host.shot(out, 'sign')
assert placed and 'sign' in placed, f'the guest placed {placed}'
host_unaffected(host, "the guest's sign")
guest.raw('key', 'esc', 'tap')
guest.raw('wait-for', 'screen', 'playing')
return placed
@@ -674,7 +700,7 @@ def check_levels(host, guest, out):
for name, step in LEVEL_STEPS:
try:
steps[name] = step(host, guest, out)
except (Failed, AssertionError, StopIteration, KeyError) as e:
except (Failed, AssertionError, StopIteration, KeyError, TypeError, IndexError) as e:
steps[name], failed = repr(e), failed + [name]
for g in (host, guest):
back_to_game(g)
@@ -747,10 +773,12 @@ def level_ticking(host, guest, out):
has its own id across the levels."""
gx, gy, gz = pos_of(guest)
hx, hy, hz = pos_of(host)
# A floor of its own beside the host (earlier checks may have broken the open one there).
bx, by, bz = int(hx // 1), int(hy // 1), int(hz // 1)
host('command', command=f'fill {bx - 4} {by - 1} {bz - 4} {bx + 4} {by - 1} {bz - 2} minecraft:stone')
host('command', command=f'fill {bx - 4} {by} {bz - 4} {bx + 4} {by + 4} {bz - 2} minecraft:air')
# Clear the whole walking area in each level: normal pig AI can leave a
# narrow landing strip during the fall/observation window.
for game, x, y, z in ((host, hx, hy, hz), (guest, gx, gy, gz)):
bx, by, bz = int(x // 1), int(y // 1), int(z // 1)
game('command', command=f'fill {bx - 12} {by - 1} {bz - 12} {bx + 12} {by - 1} {bz + 12} minecraft:stone')
game('command', command=f'fill {bx - 12} {by} {bz - 12} {bx + 12} {by + 6} {bz + 12} minecraft:air')
guest('command', command=f'summon minecraft:pig {gx + 2.5:.2f} {gy + 3:.2f} {gz:.2f}')
guest('command', command=f'summon minecraft:armor_stand {gx - 2.5:.2f} {gy + 3:.2f} {gz:.2f}')
host('command', command=f'summon minecraft:pig {hx + 2.5:.2f} {hy + 3:.2f} {hz - 3:.2f}')
@@ -762,6 +790,7 @@ def level_ticking(host, guest, out):
mobs = [m for m in g.raw('mobs', 8)['near'] if m['kind'] == 'minecraft:pig']
stands = [o for o in g('status')['objects'] if o['kind'] == 'minecraft:armor_stand']
seen[name] = {'pigs': [(m['id'], round(m['pos'][1], 2)) for m in mobs],
'pig_positions': [m['pos'] for m in mobs],
'stands': [(o['id'], round(o['position'][1], 2)) for o in stands], 'floor': floor}
finally:
guest('command', command='kill @e[type=!minecraft:player,distance=..8]')
@@ -1979,12 +2008,11 @@ def chat_input(game):
def complete(guest, text):
"""Opens the chat on `/`, types `text` and presses Tab: the input after it."""
guest.raw('key', 'slash', 'tap')
guest.wait(3)
guest.until(lambda: guest('screen').get('screen'), lambda s: s == 'chat', ticks=3)
guest.raw('type', text)
guest.wait(2)
guest.until(lambda: guest('screen').get('chat_suggestions'), bool, ticks=2)
guest.raw('key', 'tab', 'tap')
guest.wait(2)
return chat_input(guest)
return guest.until(lambda: chat_input(guest), lambda value: value != '/' + text, ticks=2)
def check_commands(host, guest, out):
@@ -2394,7 +2422,8 @@ def check_predict(host, guest, out):
seen = {}
creaks = lambda game: sum(n for s, n in game('status')['heard'].items() if 'door' in s)
heard = {'guest': creaks(guest), 'host': creaks(host)}
os.kill(host.pid, signal.SIGSTOP)
host.raw('server-hold', 'on')
held_at = host.raw('simulation')['game_time']
try:
guest.raw('interact', *wood)
seen['guest_frozen'] = (state(guest, wood), state(guest, (wood[0], wood[1] + 1, wood[2])))
@@ -2402,8 +2431,9 @@ def check_predict(host, guest, out):
seen['guest_heard_frozen'] = creaks(guest) - heard['guest']
guest.raw('interact', *iron)
seen['guest_iron_frozen'] = state(guest, iron)
assert host.raw('simulation')['game_time'] == held_at, 'server barrier advanced the world'
finally:
os.kill(host.pid, signal.SIGCONT)
host.raw('server-hold', 'off')
guest.wait(20)
seen['guest_after'] = state(guest, wood)
seen['host_after'] = state(host, wood)
@@ -2515,7 +2545,11 @@ def check_swings(host, guest, out):
guest.wait(10)
guest.raw('select', 'minecraft:wooden_hoe')
before = guest('state')['swings']
guest.raw('interact', *soil)
guest.raw('look-at', soil[0] + 0.5, soil[1] + 1, soil[2] + 0.5)
guest.wait(1)
target = guest('state')['looking_at']['block']
assert target and target['pos'] == list(soil) and target['face'] == 'up', target
guest.raw('mouse', 'right', 'click')
guest.wait(10)
hoe = {'swings': guest('state')['swings'] - before, 'soil': host.raw('block', *soil)['block']}
guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:stone')
@@ -2622,16 +2656,22 @@ def check_xp(host, guest, out):
x, y, z = pos_of(guest)
# Three orbs of 7 points 4 blocks east of the guest (out of reach, within the 8 blocks orbs
# follow from): they fly to the guest.
# Freeze setup so command/IPC latency cannot let all three be collected
# before the two-tick in-flight observation.
host('command', command='tick freeze')
for _ in range(3):
guest('command', command=f'summon minecraft:experience_orb {x + 4:.2f} {y + 0.5:.2f} {z:.2f} {{Value:7s}}')
host('command', command='tick step 2')
guest.wait(2)
seen = remote(guest).get('entities', {}).get('minecraft:experience_orb', 0)
guest.shot(out, 'xp-orbs')
host('command', command='tick unfreeze')
guest.wait(60)
xp = guest('state')['player']['xp']
host_xp = host('state')['player']['xp']
left = remote(guest).get('entities', {}).get('minecraft:experience_orb', 0)
finally:
host('command', command='tick unfreeze')
host('command', command='gamemode survival')
# Back where it stood: a spectator sinks (Player.travel keeps 0.6 of its fall speed each tick,
# about 0.2 blocks from standing), and a later check that makes it survival again would land
@@ -2657,9 +2697,7 @@ def check_time(host, guest, out):
guest.wait(20)
# A stalled game runs at most 10 missed ticks when it wakes (DeltaTracker); the server's
# game time, sent every second, moves the guest's clock on (ClientClockManager.tick).
os.kill(guest.pid, signal.SIGSTOP)
time.sleep(4)
os.kill(guest.pid, signal.SIGCONT)
guest.raw('stall', 4000)
guest.wait(60)
stalled = times()
detail = {'paused': paused, 'after_stall': stalled}
@@ -3112,12 +3150,10 @@ def same_light(host, guest, lo, hi, what, ticks=100):
def check_flight(host, guest, out):
def double_jump(game):
# LocalPlayer.aiStep: a second jump press within 7 ticks of the first toggles flight.
lines = []
for hold in (1, 4):
game.raw('key', 'space', 'down')
game.wait(hold)
game.raw('key', 'space', 'up')
game.wait(2)
game.wait(10)
lines += ['key space down', f'wait {hold}', 'key space up', 'wait 2']
game.script(lines + ['wait 10'])
return game('state')['player']
def set_mode(game, mode):
@@ -3132,6 +3168,9 @@ def check_flight(host, guest, out):
players = [('guest', guest)] + ([('host', host)] if SERVER_THREAD else [])
seen = {}
# The rule is seven simulation ticks, independent of wall time. A slow
# server clock leaves enough time to deliver the key events even on busy CI.
host('command', command='tick rate 4')
try:
for name, game in players:
set_mode(game, 'creative')
@@ -3145,6 +3184,7 @@ def check_flight(host, guest, out):
'stayed': round(later['position'][1] - up['position'][1], 2),
'survival_flying': fell['flying']}
finally:
host('command', command='tick rate 20')
for _, game in players:
game('command', command='gamemode survival')
for name, s in seen.items():
@@ -3659,15 +3699,16 @@ def leave_vehicle(player, kind):
def break_vehicle(player, vehicle_id, kind):
"""Hit it with fists until it breaks: VehicleEntity.tick takes 1 off its damage every tick."""
for _ in range(10):
here = [o for o in player('status')['objects'] if o['id'] == vehicle_id]
if not here:
return
vp = here[0]['position']
player.raw('look-at', f'{vp[0]:.2f}', f'{vp[1] + 0.3:.2f}', f'{vp[2]:.2f}')
player.raw('mouse', 'left', 'click')
player.wait(2)
raise AssertionError(f'ten hits left the {kind} in place')
here = [o for o in player('status')['objects'] if o['id'] == vehicle_id]
if not here:
return
vp = here[0]['position']
# Keep the original ten hits/two-tick spacing in one connection. Starting
# several ctl processes per hit lets the vehicle's damage decay between hits.
player.script([f'look-at {vp[0]:.2f} {vp[1] + 0.3:.2f} {vp[2]:.2f}',
*[line for _ in range(10) for line in ('mouse left click', 'wait 2')]])
assert not any(o['id'] == vehicle_id for o in player('status')['objects']), \
f'ten hits left the {kind} in place'
def check_boats(host, guest, out):
@@ -4311,10 +4352,8 @@ def check_deflect(host, guest, out):
(Player.attack: AIM_DEFLECT on #redirectable_projectile; Ghast.isReflectedFireball). The ghast is caged
(a window toward the host); the game runs at 4 ticks a second, and once the fireball is within 4.5 blocks
the host looks at the ghast and clicks."""
if SERVER_THREAD:
# The fireball's position and the host's hurts are read from the host's own level.
return 'skipped (in-frame only)'
x, y, z = (int(v // 1) for v in pos_of(host))
back = pos_of(host)
x, y, z = (int(v // 1) for v in back)
guest('command', command='gamemode creative')
host('command', command='gamemode survival')
host('command', command='difficulty normal')
@@ -4335,23 +4374,36 @@ def check_deflect(host, guest, out):
try:
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
balls = [q for q in host.raw('status')['projectiles'] if q['kind'] == 'LargeFireball']
# Aim and click at the projectile the client actually sees. Holding
# non-player simulation keeps the reach window open while ordinary
# player input and its attack packet cross the connection.
balls = [q for q in host('status')['projectiles'] if q['kind'] == 'LargeFireball']
near = min((math.dist(q['position'], eye) for q in balls), default=None)
if near is not None and near < 4.5:
ball = min(balls, key=lambda q: math.dist(q['position'], eye))
host('command', command='tick freeze')
held_tick = host.raw('simulation')['game_time']
g = [m['pos'] for m in host.raw('mobs', 60).get('near', []) if m['kind'].endswith('ghast')][0]
host.raw('look-at', f'{g[0]:.2f}', f'{g[1] + 2:.2f}', f'{g[2]:.2f}')
host.raw('mouse', 'left', 'down')
host.raw('mouse', 'left', 'up')
host.script([f'look-at {g[0]:.2f} {g[1] + 2:.2f} {g[2]:.2f}',
'wait 1', 'mouse left click', 'wait 1'])
host.until(lambda: host.raw('simulation')['projectiles'],
lambda qs: any(q.get('id') == ball['id'] and q.get('player_owned') for q in qs),
ticks=60)
assert host.raw('simulation')['game_time'] == held_tick, 'the deflection barrier advanced simulation'
host('command', command='tick unfreeze')
turned = near
break
host('command', command='tick rate 20')
host.wait(60)
ghast = [m for m in host.raw('mobs', 60).get('near', []) if m['kind'].endswith('ghast') and m['health'] > 0]
hurt = [h for pl in host.raw('hurtlog')['players'] if pl['name'] == 'host' for h in pl['hurts']
hurt = [h for pl in host.raw('hurtlog')['players'] if pl['name'] == (host.player if SERVER_THREAD else 'host') for h in pl['hurts']
if 'fireball' in h['source'] or 'explosion' in h['source']]
finally:
host('command', command='tick unfreeze')
host('command', command='tick rate 20')
host('command', command='kill @e[type=minecraft:ghast]')
host('command', command=f'tp @s {back[0]} {back[1]} {back[2]}')
host.until(lambda: pos_of(host), lambda p: math.dist(p, back) < 0.1)
host('command', command=f'fill {gx - 3} {gy - 1} {gz - 3} {gx + 24} {gy + 8} {gz + 3} minecraft:air')
guest('command', command='gamemode survival')
assert turned is not None, f'no fireball came within 4.5 blocks (nearest {near})'
@@ -4537,8 +4589,22 @@ def run(ctl, out, resources, checks):
guest.shot(out, 'joined')
for name in checks:
try:
results[name] = {'pass': True, 'detail': CHECK_FNS[name](host, guest, out)}
except (Failed, AssertionError, StopIteration, KeyError) as e:
# Cases assert their own deaths, menus and held actions before returning.
# Do not let those states turn later independent cases into false failures.
for game in (host, guest):
game.raw('release')
back_to_game(game)
state = game('state')
assert state.get('player') is not None, f'{game.name} lost its session before {name}: {state}'
if state.get('screen') == 'dead' or state['player']['health'] <= 0:
game.raw('respawn')
game.until(lambda: game('state'),
lambda s: s.get('screen') == 'playing' and s['player']['health'] > 0,
ticks=40)
detail = CHECK_FNS[name](host, guest, out)
skipped = isinstance(detail, str) and detail.startswith('skipped')
results[name] = {'pass': not skipped, 'skipped': skipped, 'detail': detail}
except (Failed, AssertionError, StopIteration, KeyError, TypeError, IndexError) as e:
results[name] = {'pass': False, 'detail': repr(e)}
for g in (host, guest):
back_to_game(g)
@@ -4557,14 +4623,19 @@ def main():
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--check', action='append', choices=CHECKS, help='run only these checks (repeatable)')
parser.add_argument('--out', default=str(ROOT / '.local/lan-e2e'), help='screenshots and game logs go here')
parser.add_argument('--server-thread', action='store_true', help="the host's world runs on its server thread")
mode = parser.add_mutually_exclusive_group()
mode.add_argument('--server-thread', dest='server_thread', action='store_true', default=True,
help='host on its server thread (default; retained for existing callers)')
mode.add_argument('--in-frame', dest='server_thread', action='store_false',
help='host the world in the window, for comparison')
args = parser.parse_args()
global SERVER_THREAD
SERVER_THREAD = args.server_thread
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client',
'--bin', 'horizon-client', '--bin', 'horizon-ctl'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
capture_output=True, text=True, check=True).stdout)
@@ -4572,9 +4643,16 @@ def main():
resources = lab.RESOURCES.resolve()
if not (resources / 'horizon-resources.json').exists():
sys.exit(f'no prepared resources in {resources}')
out = Path(args.out)
try:
require_server_reports(resources)
except ValueError as error:
sys.exit(str(error))
out = Path(args.out).resolve()
out.mkdir(parents=True, exist_ok=True)
print(f'LAN host mode: {"server-thread" if SERVER_THREAD else "in-frame"}')
results = run(bins / 'horizon-ctl', out, resources, args.check or CHECKS)
(out / 'results.json').write_text(json.dumps({'mode': 'server-thread' if SERVER_THREAD else 'in-frame',
'checks': results}, indent=2) + '\n')
print(json.dumps(results, indent=1))
sys.exit(0 if results and all(r['pass'] for r in results.values()) else 1)
@@ -4631,16 +4709,33 @@ def check_keep_inventory(host, guest, out):
neither; with it on, nothing drops and it respawns with both (#1284)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
# Totem coverage leaves both players near one health. Protect the waiting
# player from residual fire/damage while the other player's death is tested.
for game in (host, guest):
game('command', command='gamemode creative')
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5 true')
host('command', command=f'setworldspawn {hx} {hy} {hz}')
host('command', command='gamerule respawn_radius 0')
# Keep the inactive player alive and away from the death drops. Earlier
# fixtures may leave a personal respawn point or an unsafe waiting position.
host('command', command=f'fill {hx - 6} {hy - 1} {hz - 6} {hx + 6} {hy - 1} {hz + 6} minecraft:stone')
host('command', command=f'fill {hx - 6} {hy} {hz - 6} {hx + 6} {hy + 3} {hz + 6} minecraft:air')
for game, dz in ((host, -3), (guest, 3)):
waiting = (hx - 3.5, hy, hz + dz + 0.5)
game('command', command=f'spawnpoint @s {hx} {hy} {hz}')
game('command', command=f'tp @s {waiting[0]} {waiting[1]} {waiting[2]}')
game.until(lambda: pos_of(game), lambda p: math.dist(p, waiting) < 0.1)
try:
for keep in ('false', 'true'):
host('command', command=f'gamerule keep_inventory {keep}')
for game, dz in ((host, -3), (guest, 3)):
# 14 blocks from the world spawn: nothing dropped is picked up on respawning.
x, z = hx + 14, hz + dz
host('command', command=f'fill {x - 2} {hy - 1} {z - 1} {x + 2} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 2} {hy} {z - 1} {x + 2} {hy + 2} {z + 1} minecraft:air')
# Death drops scatter several blocks before landing. Keep the
# entire observation area solid so they cannot fall off a ledge.
host('command', command=f'fill {x - 6} {hy - 1} {z - 6} {x + 6} {hy - 1} {z + 6} minecraft:stone')
host('command', command=f'fill {x - 6} {hy} {z - 6} {x + 6} {hy + 3} {z + 6} minecraft:air')
host('command', command='kill @e[type=minecraft:item]')
game('command', command='gamemode survival')
game('command', command='clear @s')
@@ -4648,8 +4743,10 @@ def check_keep_inventory(host, guest, out):
game('command', command='item replace entity @s armor.head with minecraft:iron_helmet')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
game.wait(20)
assert game('state').get('screen') == 'playing', f'{game.name} died during setup'
game('command', command='kill @s')
game.wait(30)
assert game('state').get('screen') == 'dead', f'{game.name} did not die'
dropped = sorted((e['item']['item'], e['item']['count'])
for e in game.raw('entities', 8, 'minecraft:item')['entities'])
game.shot(out, f'keepinv-{keep}')
@@ -4659,12 +4756,14 @@ def check_keep_inventory(host, guest, out):
seen[f'{game.name}-keep-{keep}'] = {
'dropped': dropped,
'kept': {item: server_count(game, item) for item in ('diamond', 'iron_helmet')}}
game('command', command='gamemode creative')
finally:
host('command', command='gamerule keep_inventory false')
host('command', command='gamerule respawn_radius 10')
host('command', command='kill @e[type=minecraft:item]')
for game in (host, guest):
game('command', command='clear @s')
game('command', command='gamemode survival')
face_each_other(host, guest)
for case, s in seen.items():
want = ({'dropped': [], 'kept': {'diamond': 7, 'iron_helmet': 1}} if case.endswith('true') else
@@ -4682,6 +4781,16 @@ def check_signs(host, guest, out):
seen = {}
for game, other, dz in ((host, guest, -2), (guest, host, 2)):
spot = (hx + 2, hy, hz + dz)
for viewer in (game, other):
back_to_game(viewer)
# Earlier death/travel checks leave the players at different spawn points.
# Give this placement case its own floor, line of sight and reachable target.
host('command', command=f'fill {spot[0] - 3} {hy - 1} {spot[2] - 1} {spot[0] + 3} {hy - 1} {spot[2] + 1} minecraft:stone')
host('command', command=f'fill {spot[0] - 3} {hy} {spot[2] - 1} {spot[0] + 3} {hy + 3} {spot[2] + 1} minecraft:air')
for viewer, dx in ((game, -2), (other, 2)):
at = (spot[0] + dx + 0.5, hy, spot[2] + 0.5)
viewer('command', command=f'tp @s {at[0]} {at[1]} {at[2]}')
viewer.until(lambda: pos_of(viewer), lambda p: math.dist(p, at) < 0.1)
game('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
game('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
game('command', command='gamemode survival')
@@ -4689,7 +4798,12 @@ def check_signs(host, guest, out):
game('command', command='give @s minecraft:oak_sign 1')
game.wait(10)
game.raw('place', *spot, '--item', 'minecraft:oak_sign')
game.wait(10)
game.until(lambda: game('state').get('screen'), lambda s: s == 'sign', ticks=10)
# Drain the placement's initial block-entity data before typing. The
# reply travels on the same connection after that data, unlike a local
# screen check which may see the editor before the initial data arrives.
server_reply(game, f'data get block {spot[0]} {spot[1]} {spot[2]} front_text.messages',
'following block data')
opened = {g.name: g('state').get('screen') for g in (game, other)}
game.shot(out, 'signs-editor')
lines = [f'{game.name} wrote', 'line two']
@@ -4697,11 +4811,13 @@ def check_signs(host, guest, out):
game.raw('key', 'enter', 'tap')
game.raw('type', lines[1])
game.raw('key', 'esc', 'tap')
game.wait(20)
game.until(lambda: game('state').get('screen'), lambda s: s == 'playing', ticks=20)
back_to_game(other)
shown = {g.name: g.until(lambda: (g.raw('block', *spot).get('sign') or {}).get('front'),
lambda front: front == lines + ['', ''], ticks=20)
for g in (game, other)}
seen[game.name] = {'opened': opened,
'shown': {g.name: (g.raw('block', *spot).get('sign') or {}).get('front')
for g in (game, other)}}
'shown': shown}
other.shot(out, 'signs-shown')
for name, s in seen.items():
other = 'guest' if name == 'host' else 'host'
@@ -5052,8 +5168,12 @@ def check_fishing(host, guest, out):
tracked to the clients as a fishing bobber under its owner), lands on the ground and reels back
in; cast into open water with Lure III, a fish bites and reeling in
during the bite catches it for 1 (#1348)."""
# Both players need a safe floor for the entire other player's fishing wait.
apart(host, guest)
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='weather clear')
for game in (host, guest):
game('command', command='effect give @s minecraft:instant_health 1 5 true')
seen = {}
try:
for game, other, dz in ((host, guest, -5), (guest, host, 5)):
@@ -5369,12 +5489,8 @@ def check_digging(host, guest, out):
before = heard('minecraft:block.stone.break')
guest.raw('look-at', *at(stone))
guest.raw('mouse', 'left', 'down')
time.sleep(0.4)
os.kill(guest.pid, signal.SIGSTOP)
try:
time.sleep(1.0)
finally:
os.kill(guest.pid, signal.SIGCONT)
guest.wait(8)
guest.raw('stall', 1000)
resumed = {'guest': guest.raw('block', *stone).get('block'), 'server': host.raw('block', *stone).get('block'),
'mining': guest('state').get('mining')}
# Let go once it breaks: past it the crosshair reaches the floor.
@@ -5534,6 +5650,68 @@ CHECKS += ['knockback']
CHECK_FNS.update(knockback=check_knockback)
def check_fixture_input(host, guest, out, kind, role):
"""Complement seeded rules with real client input and authoritative observations."""
player = host if role == 'host' else guest
hx, y, hz = (int(v // 1) for v in pos_of(host))
x, z = hx + 6, hz + (-8 if role == 'host' else 8)
sensor, dispenser, button = (x + 3, y, z + 1), (x, y, z + 3), (x, y, z + 2)
player('command', command='gamemode survival')
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 5} {y - 1} {z + 5} minecraft:stone')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 5} {y + 3} {z + 5} minecraft:air')
start = (x + 0.5, y, z + 0.5)
player('command', command=f'tp @s {start[0]} {start[1]} {start[2]} 0 0')
player.until(lambda: pos_of(player), lambda p: math.dist(p, start) < 0.1)
player.raw('look', 0, 0)
try:
if kind == 'sculk':
player.wait(2) # finish setup movement before installing the listener
host.raw('setblock', *sensor, 'minecraft:sculk_sensor')
phase = lambda: host.raw('getblock', *sensor)['block']
host.until(phase, lambda state: 'sculk_sensor_phase=inactive' in state)
player.wait(10)
assert 'sculk_sensor_phase=inactive' in phase(), 'setup activated the sensor before player input'
player.raw('key', 'w', 'down')
try:
player.wait(15) # defined input duration; no wall-clock simulation sleep
finally:
player.raw('key', 'w', 'up')
moved = {'client': math.dist(pos_of(player), start),
'server': math.dist(server_player(host, player)['position'], start)}
assert all(d > 1 for d in moved.values()), f'{role} did not walk on both sides: {moved}'
active = host.until(phase, lambda state: 'sculk_sensor_phase=active' in state, ticks=85)
for viewer in (host, guest):
viewer.until(lambda: state_of(viewer, sensor, 'sculk_sensor_phase'), lambda v: v == 'active')
return {'moved': moved, 'phase': active}
host.raw('setblock', *dispenser, 'minecraft:dispenser[facing=east]')
host.raw('container', *dispenser, 'minecraft:diamond', 1)
host.raw('setblock', *button, 'minecraft:stone_button[face=wall,facing=north]')
player.until(lambda: player.raw('block', *button)['block'], lambda v: v == 'minecraft:stone_button')
player.raw('interact', *button)
slots = host.until(lambda: host.raw('container', *dispenser)['slots'], lambda v: all(s is None for s in v))
dropped = {}
for viewer in (host, guest):
dropped[viewer.name] = viewer.until(
lambda: [e for e in viewer.raw('entities', 24, 'minecraft:item')['entities']
if e.get('item', {}).get('item') == 'minecraft:diamond'
and math.dist(e['pos'], (x + 1.5, y + 0.5, z + 3.5)) < 3],
lambda drops: len(drops) == 1 and drops[0]['item']['count'] == 1)
player.shot(out, f'{kind}-input')
return {'slots': slots, 'drops': dropped}
finally:
player.raw('key', 'w', 'up')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 5} {y + 3} {z + 5} minecraft:air')
for _kind in ('sculk', 'dispenser'):
for _role in ('host', 'guest'):
_name = f'{_kind}_input_{_role}'
CHECKS.append(_name)
CHECK_FNS[_name] = lambda h, g, o, kind=_kind, role=_role: check_fixture_input(h, g, o, kind, role)
def check_hero_trading(host, guest, out):
"""Villager.updateSpecialPrices keeps the trading player's Hero effect: the offer sent to the
client and the server's payment consumption agree, independently of the other player's effect."""
+4 -1
View File
@@ -9,13 +9,16 @@ import subprocess
try:
from . import lab, test_rust_parity as base, test_model_baking_parity as baking
from .integration_env import run_game
except ImportError:
import lab, test_rust_parity as base, test_model_baking_parity as baking
from integration_env import run_game
def run_logged(command, build, name, timeout=900, **kwargs):
with (build / (name + '.log')).open('w') as log:
subprocess.run([str(c) for c in command], cwd=lab.ROOT, check=True, timeout=timeout,
runner = run_game if Path(command[0]).name == 'horizon-client' else subprocess.run
runner([str(c) for c in command], cwd=lab.ROOT, check=True, timeout=timeout,
stdout=log, stderr=subprocess.STDOUT, **kwargs)
+4 -1
View File
@@ -25,9 +25,11 @@ import sys
try:
from . import lab, test_rust_parity as base
from .integration_env import run_game
from . import command_exec_corpus as corpus_module
except ImportError:
import lab
from integration_env import run_game
import test_rust_parity as base
import command_exec_corpus as corpus_module
@@ -55,7 +57,8 @@ def main():
def run(command, name, **kw):
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
result = subprocess.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT, **kw)
runner = run_game if Path(command[0]).name == 'horizon-client' else subprocess.run
result = runner(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT, **kw)
if result.returncode:
tail = (build / (name + '.log')).read_text(errors='replace')[-3000:]
sys.exit(f'{name} failed ({result.returncode}):\n{tail}')
+9 -3
View File
@@ -65,13 +65,16 @@ import struct
import subprocess
import sys
import threading
import tempfile
import time
import zlib
try:
from . import lab, oracle_harness as oh, test_rust_parity as base
from .integration_env import game_environment, run_game
from .lab import free_port # the other server harnesses import it from here
except ImportError:
from integration_env import game_environment, run_game
import lab
import oracle_harness as oh
import test_rust_parity as base
@@ -124,9 +127,11 @@ class Server:
self.log_path = directory.parent / f'{log_name}.log'
self.log = self.log_path.open('w', encoding='utf-8')
self.lines = []
self.process = subprocess.Popen(list(map(str, command)), cwd=directory, stdin=subprocess.PIPE,
self.scratch = tempfile.TemporaryDirectory(prefix='hz-server-')
env = game_environment(os.environ, self.scratch.name)
self.process = subprocess.Popen(list(map(str, command)), cwd=directory, env=env, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
encoding='utf-8', errors='replace', env=lab.game_env())
encoding='utf-8', errors='replace')
self.reader = threading.Thread(target=self._read, daemon=True)
self.reader.start()
@@ -163,6 +168,7 @@ class Server:
self.process.wait()
self.reader.join(timeout=10)
self.log.close()
self.scratch.cleanup()
def settle(server, ticks=600, timeout=600):
@@ -359,7 +365,7 @@ def horizon_client_joins(vanilla_server, binary, world, build, reference):
server.wait_for(r'Done \(')
env = lab.game_env(HORIZON_NET_REPORT=report)
with (build / 'horizon-client-run.log').open('w') as log:
subprocess.run([str(c) for c in [binary, '--resources', lab.RESOURCES, '--render-distance', 2,
run_game([str(c) for c in [binary, '--resources', lab.RESOURCES, '--render-distance', 2,
'--server', f'127.0.0.1:{port}', '--name', PLAYER]],
cwd=client_dir, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=300, check=True)
server.wait_for(r'left the game', timeout=30)
+4 -1
View File
@@ -17,7 +17,9 @@ import random
import subprocess
try:
from . import block_update_harness as h, lab, test_rust_parity as base
from .integration_env import run_game
except ImportError:
from integration_env import run_game
import block_update_harness as h
import lab
import test_rust_parity as base
@@ -349,7 +351,8 @@ def voice(line):
def run(command, **kwargs):
done = subprocess.run(command, capture_output=True, text=True, **kwargs)
runner = run_game if str(command[0]).endswith('/horizon-client') else subprocess.run
done = runner(command, capture_output=True, text=True, **kwargs)
if done.returncode:
print((done.stdout + done.stderr)[-4000:])
raise SystemExit(f'{command[0]} failed')
+31 -16
View File
@@ -15,8 +15,9 @@ is off: nothing leaves the machine.
python3 tools/test_friends.py --client target/fast/horizon-client --relay target/fast/horizon-relay
`--server-thread` runs the host's world on its integrated server's own
thread (the window is a client of it, as Open to LAN publishes it).
The host runs on its integrated server thread by default (the window is a
client of it). `--in-frame` compares the fallback; `--server-thread` is accepted
for compatibility.
`--role host|guest` runs one side only (two machines, e.g. over a tailnet):
the host prints the code, the guest takes it with `--code` (`--relay-url`
@@ -36,8 +37,10 @@ import urllib.request
try:
from . import lab
from .integration_env import game_environment, require_server_reports
except ImportError:
import lab
from integration_env import game_environment, require_server_reports
ROOT = Path(__file__).resolve().parents[1]
@@ -84,7 +87,7 @@ class Game:
prefix = []
extra = []
# The host's world on its server thread (`--server-thread`), else in the window.
server_thread = False
server_thread = True
def __init__(self, client, resources, work, name, env):
# Either way explicitly, whatever the game's default.
@@ -96,7 +99,8 @@ class Game:
self.process = subprocess.Popen(Game.prefix + [
str(client), '--resources', str(resources), '--render-distance', '2', '--test-terrain',
'--control-socket', str(self.endpoint), '--timeout-seconds', '600',
] + Game.extra, cwd=self.home, env=lab.game_env(HORIZON_HOME=self.home, **env),
] + Game.extra, cwd=self.home,
env={**game_environment({**os.environ, **env}, self.home), 'HORIZON_HOME': str(self.home)},
stdout=self.log, stderr=subprocess.STDOUT)
wait_for(self.endpoint, 'screen', lambda v: v['screen'] == 'title', 120, f'{name} title')
@@ -149,10 +153,11 @@ def play_together(host, guest, name, round_index):
before = guest_pos(host, name)
guest('look 0 0')
guest('key w down')
time.sleep(1.5)
guest('key w up')
host.wait('lan status', lambda v: math.dist(before, next(g['position'] for g in v['guests'] if g['name'] == name)) > 1.0,
20, 'guest movement on the host')
try:
host.wait('lan status', lambda v: math.dist(before, next(g['position'] for g in v['guests'] if g['name'] == name)) > 1.0,
20, 'guest movement on the host')
finally:
guest('key w up')
# The host moves; the guest sees the host's player there.
hp = host('status')['player']['position']
target = [hp[0] + 3.0, hp[1], hp[2] + 1.0]
@@ -179,10 +184,11 @@ def play_together(host, guest, name, round_index):
guest.wait('status', lambda v: v['target']['pos'] == block, 10, 'the guest aiming at the block')
# Held for a few ticks, as a player clicks (creative breaks at once).
guest('mouse left down')
time.sleep(0.25)
guest('mouse left up')
host.wait(f'getblock {block[0]} {block[1]} {block[2]}', lambda v: v['block'] == 'minecraft:air', 20,
'the guest breaking the block, on the host')
try:
host.wait(f'getblock {block[0]} {block[1]} {block[2]}', lambda v: v['block'] == 'minecraft:air', 20,
'the guest breaking the block, on the host')
finally:
guest('mouse left up')
guest.wait(f'getblock {block[0]} {block[1]} {block[2]}', lambda v: v['block'] == 'minecraft:air', 20,
'the broken block on the guest')
@@ -250,7 +256,7 @@ def full(args, work):
join(guest, code, 'Guest')
play_together(host, guest, 'Guest', 1)
assert host('friends status')['direct'] == 1, host('friends status')
print('code alone: chat, movement and blocks both ways, no server involved')
print('code alone: chat, movement and blocks both ways, no relay involved')
leave(host, guest, 'Guest')
host('friends stop')
assert host('friends status')['code'] is None
@@ -316,8 +322,8 @@ def one_side(args, work):
if args.role == 'host':
code = host_world(game)
print(f'CODE {code}', flush=True)
game.wait('lan status', lambda v: v['guests'], args.wait, 'a guest')
game.wait('lan status', lambda v: not v['guests'], args.wait, 'the guest leaving')
game.wait('lan status', lambda v: any(g['name'] == 'Remote' for g in v['guests']), args.wait, 'a guest')
game.wait('lan status', lambda v: all(g['name'] != 'Remote' for g in v['guests']), args.wait, 'the guest leaving')
print(f'host: {game("friends status")}')
else:
join(game, args.code, 'Remote')
@@ -347,16 +353,25 @@ def main():
parser.add_argument('--hold', type=float, default=0, help='guest: stay this many seconds before leaving')
parser.add_argument('--baseline', action='store_true', help='first run the same checks over plain LAN')
parser.add_argument('--offscreen', action='store_true', help='run the games without a window')
parser.add_argument('--server-thread', action='store_true', help="the host's world on its server thread")
mode = parser.add_mutually_exclusive_group()
mode.add_argument('--server-thread', dest='server_thread', action='store_true', default=True,
help='host on its server thread (default; retained for existing callers)')
mode.add_argument('--in-frame', dest='server_thread', action='store_false',
help='host the world in the window, for comparison')
parser.add_argument('--xvfb', action='store_true', help='run the games under xvfb-run (headless Linux)')
args = parser.parse_args()
Game.prefix = ['xvfb-run', '-a'] if args.xvfb else []
Game.extra = ['--offscreen'] if args.offscreen else []
Game.server_thread = args.server_thread
args.client, args.resources = args.client.resolve(), args.resources.resolve()
try:
require_server_reports(args.resources)
except ValueError as error:
parser.error(str(error))
# Short: Unix socket paths are limited to about 100 bytes.
with tempfile.TemporaryDirectory(prefix='hzf-', dir='/tmp') as directory:
work = Path(directory)
print(f'Friends host mode: {"server-thread" if Game.server_thread else "in-frame"}')
if args.role:
return one_side(args, work)
assert args.relay, 'give --relay (the relay binary, run locally for the relay half)'
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Real clients over LAN, direct invitations and a local relay, with a server-thread host.
Registered with parity.py so changes to the drivers and the game binary select
this integration coverage in CI as well as the deterministic library probes.
"""
import json
from pathlib import Path
import subprocess
import sys
import lab
import test_friends
from integration_env import suite_lock
if __name__ == '__main__':
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client',
'-p', 'horizon-relay', '--bin', 'horizon-client', '--bin', 'horizon-ctl',
'--bin', 'horizon-relay'], cwd=lab.ROOT, check=True)
metadata = json.loads(subprocess.check_output(
['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=lab.ROOT, text=True))
bins = Path(metadata['target_directory']) / 'fast'
sys.argv = [sys.argv[0], '--client', str(bins / 'horizon-client'),
'--relay', str(bins / 'horizon-relay'), '--baseline',
*(['--offscreen'] if sys.platform == 'darwin' else [])]
with suite_lock():
# The wrapper only selects binaries; test_friends isolates each game
# through integration_env and lab.game_env.
test_friends.main()
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Register the real-client LAN suite with parity.py's traced harness selection.
The assertions live in lan_e2e; this is integration coverage, alongside the
fast vanilla differential probes. The host uses the normal server thread.
Linux runners need a display (for example xvfb-run -a).
"""
import lan_e2e
from integration_env import suite_lock
if __name__ == '__main__':
with suite_lock():
lan_e2e.main()
+3 -1
View File
@@ -50,11 +50,13 @@ import urllib.parse
try:
from . import lab, oracle_harness as oh, test_rust_parity as base
from .test_dedicated_server_parity import Server, free_port
from .integration_env import run_game
except ImportError:
import lab
import oracle_harness as oh
import test_rust_parity as base
from test_dedicated_server_parity import Server, free_port
from integration_env import run_game
NAME = 'online-mode-parity'
PLAYER = 'Tester'
@@ -185,7 +187,7 @@ def run_client(binary, build, port, label, token, discovery):
if token:
command += ['--session-file', session_file(directory, token)]
with (build / f'client-{label}.log').open('w') as log:
subprocess.run([str(c) for c in command], cwd=directory, env=env, stdout=log, stderr=subprocess.STDOUT,
run_game([str(c) for c in command], cwd=directory, env=env, stdout=log, stderr=subprocess.STDOUT,
timeout=300, check=True)
got = json.loads(report.read_text())
got['session file left'] = (directory / 'session.json').exists()
+1 -1
View File
@@ -147,7 +147,7 @@ def main():
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-cp', dependencies, '-d', classes,
*(PARITY / f'{n}.java' for n in ('ParityData', 'ParityFont', 'VanillaPackScreenOracle'))], 'compile-java')
run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaPackScreenOracle',
inputs, build / 'vanilla.jsonl'], 'vanilla')
inputs, build / 'vanilla.jsonl', lab.RESOURCES / 'assets'], 'vanilla')
lib.cargo_example('horizon-client', 'pack_screen_fixture', lib.RESOURCES, inputs, build / 'rust.jsonl')
vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').read_text().splitlines()]
rust = [json.loads(l) for l in (build / 'rust.jsonl').read_text().splitlines()]
+1 -1
View File
@@ -240,7 +240,7 @@ def main():
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-cp', dependencies, '-d', classes,
*(PARITY / f'{n}.java' for n in ('ParityData', 'ParityFont', 'VanillaTabListOracle', 'VanillaSkinImageOracle'))], 'compile-java')
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaTabListOracle',
inputs, build / 'vanilla.jsonl'], 'vanilla')
inputs, build / 'vanilla.jsonl', resources / 'assets'], 'vanilla')
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', 'client_probe', '--',
'tab_list_fixture', resources, inputs, build / 'rust.jsonl'], 'rust')
images = build / 'skins.txt'; images.write_text(''.join(i + '\n' for i in skin_images(args.images)))