Files
settled-reach/server/tests/gen_gauntlet_fixtures.rs
T
jpmschweitzer 920ea0582f feat(simulation): thread world seed from StartupMessage into economy (#826)
Replaces the hardcoded seed=0 with the seed received in StartupMessage,
threading it through SimulationPlugin -> EconomyPlugin / SimRng. Integration
test fixtures updated for the new SimulationPlugin { seed } signature.
2026-04-14 17:21:53 +02:00

187 lines
7.0 KiB
Rust

//! 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 { seed: 0 });
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::<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);
}
}