feat(engine): live server visual tests and gauntlet snapshot replay
Add live server lifecycle to tests/run-visual (start/stop server per
scenario, parse LISTENING:{port}). Add MessagePack snapshot replay to
visual_capture.gd via Protocol.decode_snapshot() — exercises the full
client pipeline from wire bytes to rendered fog. Three replay scenarios
(hub_spawn, fog_theater, hub_after_movement) plus one live scenario
(fog_live_hub). Add gen_gauntlet_fixtures.rs to produce .msgpack fixtures
from the Gauntlet test world. Add max_diff_pct threshold to visual-diff.
Makefile: add fixtures-gauntlet target, fix build-client double-import,
preserve .godot cache in clean.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
//! Generate snapshot fixtures from the Gauntlet test world (D-030 full pipeline).
|
||||
//!
|
||||
//! Runs the full Gauntlet simulation pipeline — same code path as --test-mode —
|
||||
//! then serializes ObserverSnapshots to both MessagePack (wire format) and JSON
|
||||
//! (human-readable debug) for client-side visual testing.
|
||||
//!
|
||||
//! The .msgpack files are the actual wire-format bytes the server sends over IPC.
|
||||
//! Client visual tests load them via Protocol.decode_snapshot() → GameState.apply_snapshot(),
|
||||
//! exercising the exact same pipeline as the live game.
|
||||
//!
|
||||
//! The .json files are for human inspection only.
|
||||
//!
|
||||
//! Run with: cargo test --test gen_gauntlet_fixtures -- --ignored
|
||||
//! Or: make fixtures-gauntlet
|
||||
//!
|
||||
//! Output: tests/fixtures/gauntlet/*.{msgpack,json} (relative to repo root)
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
|
||||
use settled_reach_server::knowledge::KnowledgePlugin;
|
||||
use settled_reach_server::npc::NpcPlugin;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use settled_reach_server::simulation::rng::SimRng;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
use settled_reach_server::test_world;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const SEED: u64 = 42;
|
||||
const FIXTURE_DIR: &str = "../tests/fixtures/gauntlet";
|
||||
|
||||
/// Build a deterministic Gauntlet simulation app.
|
||||
/// Identical to what the server runs in --test-mode.
|
||||
fn build_gauntlet(seed: u64) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
app.insert_resource(SimRng::new(seed));
|
||||
|
||||
test_world::setup_gauntlet(&mut app);
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Teleport the player to a specific position and facing.
|
||||
/// Directly modifies ECS components — same effect as PlayerAction::Teleport
|
||||
/// but without needing a target room action.
|
||||
fn teleport_player(app: &mut App, pos: TilePosition, facing: Facing) {
|
||||
let player = {
|
||||
let mut query = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<PlayerCharacter>>();
|
||||
query.single(app.world()).expect("player entity must exist")
|
||||
};
|
||||
app.world_mut()
|
||||
.entity_mut(player)
|
||||
.insert((pos, facing));
|
||||
}
|
||||
|
||||
/// Run N ticks, feeding inputs each tick, return the last snapshot.
|
||||
fn run_ticks(app: &mut App, inputs: &[Vec<PlayerInput>]) -> ObserverSnapshot {
|
||||
let mut last_snapshot: Option<ObserverSnapshot> = None;
|
||||
|
||||
for tick_inputs in inputs {
|
||||
{
|
||||
let mut queue = app
|
||||
.world_mut()
|
||||
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
|
||||
for input in tick_inputs {
|
||||
queue.push(input.clone());
|
||||
}
|
||||
}
|
||||
|
||||
app.update();
|
||||
|
||||
let buffer = app.world().resource::<SnapshotBuffer>();
|
||||
if let Some(snapshot) = &buffer.snapshot {
|
||||
last_snapshot = Some(snapshot.clone());
|
||||
}
|
||||
}
|
||||
|
||||
last_snapshot.expect("no snapshot produced")
|
||||
}
|
||||
|
||||
/// Recursively sort all object keys for deterministic JSON output.
|
||||
fn sort_json_keys(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let sorted: BTreeMap<String, Value> = map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
|
||||
.collect();
|
||||
Value::Object(sorted.into_iter().collect())
|
||||
}
|
||||
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_fixture(name: &str, snapshot: &ObserverSnapshot) {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_DIR);
|
||||
fs::create_dir_all(&dir).expect("create fixture dir");
|
||||
|
||||
// MessagePack — exact wire-format bytes the server sends over IPC.
|
||||
// Client visual tests load these via Protocol.decode_snapshot().
|
||||
let msgpack = rmp_serde::to_vec_named(snapshot).expect("serialize to MessagePack");
|
||||
let msgpack_path = dir.join(format!("{}.msgpack", name));
|
||||
fs::write(&msgpack_path, &msgpack).expect("write msgpack fixture");
|
||||
|
||||
// JSON — human-readable debug companion (not loaded by tests).
|
||||
let value: Value = serde_json::to_value(snapshot).expect("serialize to JSON");
|
||||
let sorted = sort_json_keys(&value);
|
||||
let json = serde_json::to_string_pretty(&sorted).expect("format JSON") + "\n";
|
||||
let json_path = dir.join(format!("{}.json", name));
|
||||
fs::write(&json_path, &json).expect("write json fixture");
|
||||
|
||||
eprintln!(
|
||||
"Wrote {} ({} bytes msgpack, {} bytes json, {} visible_tiles, {} entities)",
|
||||
name,
|
||||
msgpack.len(),
|
||||
json.len(),
|
||||
snapshot.visible_tiles.len(),
|
||||
snapshot.entities.len(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Run manually: cargo test --test gen_gauntlet_fixtures -- --ignored
|
||||
fn generate_gauntlet_snapshot_fixtures() {
|
||||
// --- Hub (default spawn position) ---
|
||||
// Player at (50, 58) facing North — tests fog rendering at the starting location.
|
||||
// This is the exact state a new player sees on connect.
|
||||
{
|
||||
let mut app = build_gauntlet(SEED);
|
||||
// Run 3 idle ticks to stabilize (cognitive delay, vision cone init)
|
||||
let inputs: Vec<Vec<PlayerInput>> = vec![vec![], vec![], vec![]];
|
||||
let snapshot = run_ticks(&mut app, &inputs);
|
||||
write_fixture("hub_spawn", &snapshot);
|
||||
}
|
||||
|
||||
// --- Fog Theater (observer position) ---
|
||||
// Player at (56, 18) facing South — large open room with NPCs at varying distances.
|
||||
// Tests visibility cone, fog layers, and distance-based fog rendering.
|
||||
{
|
||||
let mut app = build_gauntlet(SEED);
|
||||
let fog_theater = test_world::constants::FOG_THEATER;
|
||||
teleport_player(&mut app, fog_theater.observer, fog_theater.observer_facing);
|
||||
let inputs: Vec<Vec<PlayerInput>> = vec![vec![], vec![], vec![]];
|
||||
let snapshot = run_ticks(&mut app, &inputs);
|
||||
write_fixture("fog_theater", &snapshot);
|
||||
}
|
||||
|
||||
// --- Hub after movement (explored tiles + visible tiles differ) ---
|
||||
// Player moves south from hub, creating a mix of explored-but-not-visible
|
||||
// and currently-visible tiles — the exact fog boundary condition.
|
||||
{
|
||||
let mut app = build_gauntlet(SEED);
|
||||
let inputs: Vec<Vec<PlayerInput>> = vec![
|
||||
vec![],
|
||||
vec![PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveSouth,
|
||||
}],
|
||||
vec![PlayerInput {
|
||||
tick: 2,
|
||||
action: PlayerAction::MoveSouth,
|
||||
}],
|
||||
vec![PlayerInput {
|
||||
tick: 3,
|
||||
action: PlayerAction::MoveSouth,
|
||||
}],
|
||||
vec![], // idle — snapshot has explored + visible tiles that differ
|
||||
];
|
||||
let snapshot = run_ticks(&mut app, &inputs);
|
||||
write_fixture("hub_after_movement", &snapshot);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user