mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Migrate player integration coverage to server-thread hosting
Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
This commit is contained in:
1 parent
8177a6f450
commit
bd09f8b03e
29 files changed
+932
-100
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}));
|
||||
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)'
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user