//! 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::(); 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::(); 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![ // 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 = 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) { 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 = 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()); } } 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); } }