feat(simulation): sprint 9 gauntlet — test infrastructure and first 3 rooms

Add Gauntlet test world with 3 rooms (Inventory Warehouse, Occlusion
Corridor, Pause Chamber) + Central Hub, room constants module, room
reset trigger mechanism, Layer 3 subprocess integration test, golden
file comparison engine and test suite, and content runtime validation.

Tickets: #482, #484, #485, #487, #488, #489, #490

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 02:25:57 +01:00
co-authored by Claude Opus 4.6
parent 58072bc119
commit e66352e0ea
19 changed files with 7906 additions and 23 deletions
+130
View File
@@ -3,6 +3,9 @@
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
//! Uses real content files from content/ directory for structural content,
//! and a test fixture for isolated NPC profile spawning.
//!
//! Also includes runtime validation (#489): boot the full plugin stack with real
//! content, tick 10 times over TCP, and assert a valid ObserverSnapshot.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
@@ -416,3 +419,130 @@ fn spawn_real_content_with_relationships_and_secrets() {
.expect("Nils should have Want");
assert_eq!(nils_want.primary, npc::WantKind::Power);
}
// -----------------------------------------------------------------------
// Test: Runtime validation — boot + tick 10 + snapshot (#489)
//
// Smoke test for production content. Boots the full plugin stack with real
// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot.
// Catches runtime panics from broken entity references, missing components,
// or content schema issues that pass YAML validation but fail at tick time.
// -----------------------------------------------------------------------
#[test]
fn content_runtime_boot_tick_10_snapshot() {
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::listening::ListeningFocus;
use settled_reach_server::simulation::monologue::{
MonologueBuffer, MonologueState, SprintAnomalyQueue,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use std::io::{BufReader, BufWriter};
use std::net::{TcpListener, TcpStream};
use std::thread;
let root = content_root();
if !root.join("content.yaml").exists() {
eprintln!("Skipping: content directory not found at {:?}", root);
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
// Server thread: full plugin stack with real content
let server_handle = thread::spawn(move || {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.insert_resource(ContentConfig {
content_root: root,
..Default::default()
});
app.add_plugins(ContentPlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
// Spawn player with all required observer pipeline components
let profile = MovementProfile::smuggler();
app.world_mut().spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
));
// Tick 10 times — any panic here means content has a runtime bug
for _ in 0..10 {
app.update();
}
});
// Client: connect and receive 10 snapshots
let stream = TcpStream::connect(server_addr).expect("client connect");
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
let mut writer = BufWriter::new(stream);
let mut last_snapshot = None;
for tick in 0..10 {
let payload = read_framed(&mut reader)
.unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e))
.unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick));
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
last_snapshot = Some(snapshot);
// Send empty input for next tick
let empty: Vec<PlayerInput> = vec![];
let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input");
if let Err(_) = write_framed(&mut writer, &input_payload) {
// Server may have shut down after tick 10 — that's fine
break;
}
}
drop(reader);
drop(writer);
// Server thread must not have panicked
server_handle
.join()
.expect("server thread panicked — content triggered a runtime error during tick processing");
// Validate final snapshot
let snapshot = last_snapshot.expect("should have received at least one snapshot");
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"snapshot protocol version mismatch"
);
// Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default)
// The player is at 16,16 — content NPCs are far away but the player entity itself
// should always be in the snapshot
assert!(
!snapshot.entities.is_empty(),
"snapshot should contain at least the player entity"
);
}
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
//! Golden file regression test (#485)
//!
//! Runs a 10-tick deterministic replay, serializes the final ObserverSnapshot
//! to JSON, and compares against a committed golden file. Any deviation fails
//! the test with a field-level diff.
//!
//! To regenerate golden files after intentional changes:
//! UPDATE_GOLDEN=1 cargo test --test golden_suite
//!
//! Spec references: D-010 (deterministic simulation), D-030 (testability)
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
use settled_reach_server::npc::{
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold,
Want, WantKind,
};
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
use settled_reach_server::simulation::listening::ListeningFocus;
use settled_reach_server::simulation::monologue::{
MonologueBuffer, MonologueState, SprintAnomalyQueue,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::rng::SimRng;
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::time::DayPhase;
use settled_reach_server::simulation::SimulationPlugin;
use bevy_app::prelude::*;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf;
const GOLDEN_DIR: &str = "tests/golden";
const GOLDEN_FILE: &str = "tests/golden/proof_room_tick_10.json";
const SEED: u64 = 42;
const NUM_TICKS: usize = 10;
/// Build a deterministic simulation app with the proof room.
/// Mirrors the setup in determinism.rs / main.rs.
fn build_app(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));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
{
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
}
let mut registry = EntityRegistry::new(0);
let profile = MovementProfile::smuggler();
let player = app
.world_mut()
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
let npc1 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(16, 13, 0),
Want {
primary: WantKind::Wealth,
intensity: 6,
description: "Wants a bigger share of docking fees".into(),
},
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(16, 13, 0),
activity: "Prep cargo bay".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(20, 10, 0),
activity: "Unload freight".into(),
},
RoutineEntry {
phase: DayPhase::Evening,
location: TilePosition::new(10, 20, 0),
activity: "Drink at canteen".into(),
},
RoutineEntry {
phase: DayPhase::Night,
location: TilePosition::new(16, 13, 0),
activity: "Sleep in bunk".into(),
},
],
description: "Dock worker shift pattern".into(),
},
Contentment { level: 20 },
ToleranceThreshold {
current_stress: 30,
threshold: 70,
},
MovementSpeed::new(2),
))
.id();
let npc1_sid = registry.register(npc1);
let npc2 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(14, 18, 0),
Want {
primary: WantKind::Knowledge,
intensity: 8,
description: "Obsessed with pre-Collapse sensor arrays".into(),
},
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(14, 18, 0),
activity: "Calibrate instruments".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(22, 22, 0),
activity: "Field survey".into(),
},
],
description: "Field tech survey pattern".into(),
},
Contentment { level: 45 },
ToleranceThreshold {
current_stress: 10,
threshold: 60,
},
MovementSpeed::default(),
))
.id();
let npc2_sid = registry.register(npc2);
let npc3 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(18, 14, 0),
Want {
primary: WantKind::Safety,
intensity: 4,
description: "Wants a quiet shift".into(),
},
Contentment { level: -5 },
ToleranceThreshold {
current_stress: 45,
threshold: 55,
},
))
.id();
let npc3_sid = registry.register(npc3);
{
let mut rel_graph = app.world_mut().resource_mut::<RelationshipGraph>();
rel_graph.set_relationship(
npc1_sid,
npc3_sid,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 3,
history: vec![],
last_interaction_tick: 0,
},
);
rel_graph.set_relationship(
npc3_sid,
npc2_sid,
RelationshipEdge {
kind: RelationshipKind::Rival,
trust: -4,
history: vec![],
last_interaction_tick: 0,
},
);
}
app.insert_resource(registry);
app
}
/// Standard 10-tick input sequence for golden file tests.
/// Matches the first 10 ticks of the determinism test in determinism.rs.
fn standard_inputs() -> Vec<Vec<PlayerInput>> {
vec![
// Tick 0: idle — baseline snapshot
vec![],
// Tick 1: move north
vec![PlayerInput {
tick: 1,
action: PlayerAction::MoveNorth,
}],
// Tick 2: idle
vec![],
// Tick 3: move east
vec![PlayerInput {
tick: 3,
action: PlayerAction::MoveEast,
}],
// Tick 4: idle
vec![],
// Tick 5: move north
vec![PlayerInput {
tick: 5,
action: PlayerAction::MoveNorth,
}],
// Tick 6: stance toggle up
vec![PlayerInput {
tick: 6,
action: PlayerAction::ToggleStanceUp,
}],
// Tick 7: move north
vec![PlayerInput {
tick: 7,
action: PlayerAction::MoveNorth,
}],
// Tick 8: pause
vec![PlayerInput {
tick: 8,
action: PlayerAction::Pause,
}],
// Tick 9: move while paused (should be discarded)
vec![PlayerInput {
tick: 9,
action: PlayerAction::MoveNorth,
}],
]
}
/// 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(),
}
}
/// Recursive JSON diff — reports all field-level differences with paths.
fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => {
let mut all_keys: Vec<&String> = e.keys().chain(a.keys()).collect();
all_keys.sort();
all_keys.dedup();
for key in all_keys {
let child = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
match (e.get(key), a.get(key)) {
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
(None, None) => unreachable!(),
}
}
}
(Value::Array(e), Value::Array(a)) => {
for i in 0..e.len().max(a.len()) {
let child = format!("{}[{}]", path, i);
match (e.get(i), a.get(i)) {
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
(None, None) => unreachable!(),
}
}
}
_ => {
if expected != actual {
diffs.push(format!(
"{}: expected {}, got {}",
path, expected, actual
));
}
}
}
}
#[test]
fn proof_room_tick_10_matches_golden() {
let mut app = build_app(SEED);
let inputs = standard_inputs();
assert_eq!(inputs.len(), NUM_TICKS);
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());
}
}
let snapshot = last_snapshot.expect("no snapshot produced after 10 ticks");
// Serialize to sorted JSON for deterministic comparison
let actual_value: Value = serde_json::to_value(&snapshot).expect("serialize to JSON");
let actual_sorted = sort_json_keys(&actual_value);
let actual_json =
serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n";
let golden_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE);
// UPDATE_GOLDEN=1 mode: write the golden file and return
if std::env::var("UPDATE_GOLDEN").is_ok() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_DIR);
std::fs::create_dir_all(&dir).expect("create golden directory");
std::fs::write(&golden_path, &actual_json).expect("write golden file");
eprintln!(
"Golden file written: {} ({} bytes)",
golden_path.display(),
actual_json.len()
);
return;
}
// Normal mode: compare against golden file
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
panic!(
"Golden file not found: {}. Run with UPDATE_GOLDEN=1 to generate.\nError: {}",
golden_path.display(),
e
)
});
let golden_value: Value =
serde_json::from_str(&golden_json).expect("parse golden file as JSON");
let mut diffs = Vec::new();
diff_json("", &golden_value, &actual_sorted, &mut diffs);
if !diffs.is_empty() {
let mut msg = format!(
"Golden file mismatch ({} differences):\n",
diffs.len()
);
for diff in &diffs {
msg.push_str(&format!(" {}\n", diff));
}
msg.push_str(&format!(
"\nTo update: UPDATE_GOLDEN=1 cargo test --test golden_suite\n\
Golden file: {}",
golden_path.display()
));
panic!("{}", msg);
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Layer 3 integration test: real subprocess IPC (D-030)
//!
//! Spawns the server binary as a child process with --test-mode --port 0,
//! parses the LISTENING:{port} handshake from stdout, connects via TCP,
//! sends a PlayerInput, and reads back an ObserverSnapshot.
//!
//! This is the highest-fidelity test layer: no mocks, no in-process bridge.
//! The server runs as a separate OS process, exactly as it does in production.
//!
//! Spec references: D-020 (subprocess IPC), D-030 (Layer 3 integration tests)
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::types::*;
use std::io::{BufRead, BufReader, BufWriter};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Timeout for the server to emit LISTENING:{port} on stdout.
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
/// Timeout for the client to receive a snapshot after sending input.
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
#[test]
fn server_subprocess_sends_snapshot_on_connect() {
// 1. Spawn server binary with --test-mode --port 0
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
let mut child = Command::new(server_bin)
.args(["--test-mode", "--port", "0"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn server binary");
let stdout = child.stdout.take().expect("stdout not captured");
let mut stdout_reader = BufReader::new(stdout);
// 2. Parse LISTENING:{port} from stdout
let port = {
let deadline = Instant::now() + LISTEN_TIMEOUT;
let mut line = String::new();
loop {
line.clear();
match stdout_reader.read_line(&mut line) {
Ok(0) => panic!("server stdout closed before LISTENING signal"),
Ok(_) => {
let trimmed = line.trim();
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
break port_str
.parse::<u16>()
.unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e));
}
}
Err(e) => panic!("failed to read server stdout: {}", e),
}
assert!(
Instant::now() < deadline,
"timed out waiting for LISTENING signal"
);
}
};
// 3. Connect to the server via TCP
let addr = format!("127.0.0.1:{}", port);
let stream = TcpStream::connect(&addr)
.unwrap_or_else(|e| panic!("failed to connect to server at {}: {}", addr, e));
stream
.set_read_timeout(Some(SNAPSHOT_TIMEOUT))
.expect("set read timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader"));
let mut writer = BufWriter::new(stream);
// 4. Send one PlayerInput (idle tick 0)
let inputs = vec![PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
}];
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput");
write_framed(&mut writer, &payload).expect("send PlayerInput to server");
// 5. Read one ObserverSnapshot
let response = read_framed(&mut reader)
.expect("read snapshot frame")
.expect("server closed connection before sending snapshot");
let snapshot: ObserverSnapshot =
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
// 6. Assert protocol correctness (D-020)
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch: got {}, expected {}",
snapshot.version, PROTOCOL_VERSION
);
assert!(
snapshot.entities.len() > 0,
"snapshot should contain at least one entity (the player), got 0"
);
// The proof room has a player + NPCs. Verify the player entity exists.
let has_player = snapshot
.entities
.iter()
.any(|e| matches!(e.kind, EntityKind::Player));
assert!(has_player, "snapshot must contain a Player entity");
// 7. Clean up: drop connection so the server exits its game loop
drop(reader);
drop(writer);
// Wait for child to exit (with timeout)
let exit_deadline = Instant::now() + Duration::from_secs(5);
loop {
match child.try_wait() {
Ok(Some(_status)) => break,
Ok(None) => {
if Instant::now() > exit_deadline {
child.kill().ok();
child.wait().ok();
break;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
eprintln!("error waiting for server process: {}", e);
child.kill().ok();
break;
}
}
}
}