//! 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, settled_reach_server::bridge::types::CharacterArchetype::default(), ); 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::>(); 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]) -> ObserverSnapshot { let mut last_snapshot: Option = None; for tick_inputs in inputs { { let mut queue = app .world_mut() .resource_mut::(); for input in tick_inputs { queue.push(input.clone()); } } app.update(); let buffer = app.world().resource::(); 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 = 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![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![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![ 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); } }