diff --git a/AGENTS.md b/AGENTS.md index a72c6723a..c8bfcdb34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 # host in-frame, guest is a server client -python3 tools/lan_e2e.py --server-thread --check # host on its server thread (portals, levels, distance, lava need it) +python3 tools/lan_e2e.py --check # host on its server thread, guest is a server client +python3 tools/lan_e2e.py --in-frame --check # comparison (portals, levels, distance, lava need server-thread mode) python3 tools/parity.py dedicated_server server_entities server_player_damage ``` diff --git a/crates/horizon-client/src/bin/horizon-ctl/main.rs b/crates/horizon-client/src/bin/horizon-ctl/main.rs index 402808e81..33ea1f870 100644 --- a/crates/horizon-client/src/bin/horizon-ctl/main.rs +++ b/crates/horizon-client/src/bin/horizon-ctl/main.rs @@ -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] diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a246afebc..5901c3362 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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() { diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 57c12da97..935717eb5 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -300,6 +300,8 @@ struct Glide { pub(super) struct Agent { task: Option, glide: Option, + /// 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(); diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index a0d8987d8..a7312c99b 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -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" => { diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index c4779dbf7..60f3d57db 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -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 = self .mobs .iter() diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index d4726f2f1..21e46f51e 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -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. diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index bc6d0b13e..e70426a4a 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -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)})); diff --git a/docs/architecture.md b/docs/architecture.md index f95073e57..0350bd1ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/parity-workflow.md b/docs/parity-workflow.md index f9f222071..3834234c9 100644 --- a/docs/parity-workflow.md +++ b/docs/parity-workflow.md @@ -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//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. diff --git a/docs/validation/server-thread-migration.md b/docs/validation/server-thread-migration.md new file mode 100644 index 000000000..62c1609b7 --- /dev/null +++ b/docs/validation/server-thread-migration.md @@ -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. diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/ParityFont.java b/mods/horizon/src/test/java/dev/horizonlab/parity/ParityFont.java index 09e9911d6..3390b37f6 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/ParityFont.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/ParityFont.java @@ -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 providers = new ArrayList<>(); + loadProviders(resources, Identifier.withDefaultNamespace("default"), providers, new HashSet<>()); + return create(providers); + } + + private static void loadProviders(ResourceManager resources, Identifier font, List providers, + Set 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 providers) { GlyphSource source = new GlyphSource() { @Override public BakedGlyph getGlyph(int codepoint) { diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPackScreenOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPackScreenOracle.java index 9b3509e94..d3513d3ac 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPackScreenOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPackScreenOracle.java @@ -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); diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaTabListOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaTabListOracle.java index b48d45041..532c8823c 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaTabListOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaTabListOracle.java @@ -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(); + var lookups = new ArrayList>(); 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()); diff --git a/tests/test_game_home.py b/tests/test_game_home.py index b4d9e017f..b0561a9a2 100644 --- a/tests/test_game_home.py +++ b/tests/test_game_home.py @@ -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", } diff --git a/tests/test_integration_env.py b/tests/test_integration_env.py new file mode 100644 index 000000000..07ba97a1b --- /dev/null +++ b/tests/test_integration_env.py @@ -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) diff --git a/tools/agent_control_e2e.py b/tools/agent_control_e2e.py index 7f0c52bce..116aed49b 100644 --- a/tools/agent_control_e2e.py +++ b/tools/agent_control_e2e.py @@ -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 diff --git a/tools/integration_env.py b/tools/integration_env.py new file mode 100644 index 000000000..ed7122c1e --- /dev/null +++ b/tools/integration_env.py @@ -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 diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index f79e07033..ec3b58913 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -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.""" diff --git a/tools/oracle_harness.py b/tools/oracle_harness.py index 92e2a26a2..787c8a293 100644 --- a/tools/oracle_harness.py +++ b/tools/oracle_harness.py @@ -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) diff --git a/tools/test_command_exec_parity.py b/tools/test_command_exec_parity.py index 877bd0b38..5761ef485 100644 --- a/tools/test_command_exec_parity.py +++ b/tools/test_command_exec_parity.py @@ -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}') diff --git a/tools/test_dedicated_server_parity.py b/tools/test_dedicated_server_parity.py index 49a934987..ee0eb46f5 100644 --- a/tools/test_dedicated_server_parity.py +++ b/tools/test_dedicated_server_parity.py @@ -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) diff --git a/tools/test_dispenser_parity.py b/tools/test_dispenser_parity.py index a6d1f429a..04f599e34 100644 --- a/tools/test_dispenser_parity.py +++ b/tools/test_dispenser_parity.py @@ -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') diff --git a/tools/test_friends.py b/tools/test_friends.py index cc5d15b92..fd3d626a9 100644 --- a/tools/test_friends.py +++ b/tools/test_friends.py @@ -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)' diff --git a/tools/test_friends_integration_parity.py b/tools/test_friends_integration_parity.py new file mode 100644 index 000000000..c97787803 --- /dev/null +++ b/tools/test_friends_integration_parity.py @@ -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() diff --git a/tools/test_lan_integration_parity.py b/tools/test_lan_integration_parity.py new file mode 100644 index 000000000..773a7cd82 --- /dev/null +++ b/tools/test_lan_integration_parity.py @@ -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() diff --git a/tools/test_online_mode_parity.py b/tools/test_online_mode_parity.py index d03838bf3..8096efd80 100644 --- a/tools/test_online_mode_parity.py +++ b/tools/test_online_mode_parity.py @@ -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() diff --git a/tools/test_pack_screen_parity.py b/tools/test_pack_screen_parity.py index 1f3f03ede..77249ec5e 100644 --- a/tools/test_pack_screen_parity.py +++ b/tools/test_pack_screen_parity.py @@ -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()] diff --git a/tools/test_tab_list_parity.py b/tools/test_tab_list_parity.py index 4075859e7..3d7c84d62 100644 --- a/tools/test_tab_list_parity.py +++ b/tools/test_tab_list_parity.py @@ -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)))