//! Determinism regression test (#466) //! //! Master guard for D-010 principle 4: given the same seed and input sequence, //! the simulation must produce byte-identical snapshots across runs. //! //! Exercises all three determinism fixes: //! - Fix A (#456): BTreeSet for visible_ids + sorted visible_tiles //! - Fix B (#457): Entities sorted by entity_id in snapshot //! - Fix D (#458): Movers sorted by Entity::to_bits() in collision resolution use bevy_app::prelude::*; 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; /// Build a fully-initialized simulation app with the proof room. /// No BridgeResource — bridge systems become no-ops. /// Snapshots are written to SnapshotBuffer for direct inspection. fn build_deterministic_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); // Override SimRng with deterministic seed app.insert_resource(SimRng::new(seed)); // --- Proof room setup (mirrors main.rs setup_proof_room) --- 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); // Player at (16,16) 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); // NPC 1: Dock worker at (16,13) — behind wall, full routine 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); // NPC 2: Field tech at (14,18) — visible to player, has routine 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); // NPC 3: Guard at (18,14) — stationary, no routine 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); // Relationships { 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 } /// Run the simulation for a fixed number of ticks with predetermined inputs. /// Returns serialized snapshots for each tick. fn run_simulation( seed: u64, inputs: &[Vec], ) -> Vec> { let mut app = build_deterministic_app(seed); let mut snapshots = Vec::with_capacity(inputs.len()); for tick_inputs in inputs { // Push inputs into the queue before the tick runs { let mut queue = app .world_mut() .resource_mut::(); for input in tick_inputs { queue.push(input.clone()); } } app.update(); // Read snapshot from buffer (send_bridge_snapshot is a no-op without BridgeResource) let buffer = app.world().resource::(); if let Some(snapshot) = &buffer.snapshot { let bytes = rmp_serde::to_vec_named(snapshot).expect("serialize snapshot"); snapshots.push(bytes); } } snapshots } #[test] fn gauntlet_deterministic_replay() { // D-010 principle 4: same seed + same inputs → byte-identical snapshots. // // Input sequence exercises: // - Idle ticks (baseline determinism) // - Player movement in cardinal directions (movement validation, visibility changes) // - Stance changes (movement profile system) // - Pause/unpause (time control determinism) let inputs: Vec> = vec![ // Tick 0: idle — establishes baseline snapshot vec![], // Tick 1: move north — player enters NPC 2's vicinity, changes visibility set vec![PlayerInput { tick: 1, action: PlayerAction::MoveNorth, }], // Tick 2: idle — NPC routines may generate pathfinding vec![], // Tick 3: move east — tests different movement direction vec![PlayerInput { tick: 3, action: PlayerAction::MoveEast, }], // Tick 4: idle vec![], // Tick 5: move north again — approaching wall at (16,14) vec![PlayerInput { tick: 5, action: PlayerAction::MoveNorth, }], // Tick 6: stance toggle — changes movement profile vec![PlayerInput { tick: 6, action: PlayerAction::ToggleStanceUp, }], // Tick 7: move north — sprint speed if stance changed vec![PlayerInput { tick: 7, action: PlayerAction::MoveNorth, }], // Tick 8: pause vec![PlayerInput { tick: 8, action: PlayerAction::Pause, }], // Tick 9: movement while paused — should be discarded vec![PlayerInput { tick: 9, action: PlayerAction::MoveNorth, }], // Tick 10: unpause vec![PlayerInput { tick: 10, action: PlayerAction::Unpause, }], // Tick 11: move west — tests westward visibility vec![PlayerInput { tick: 11, action: PlayerAction::MoveWest, }], // Tick 12: move south — reverses direction vec![PlayerInput { tick: 12, action: PlayerAction::MoveSouth, }], // Ticks 13-19: idle ticks to let NPC routines/pathfinding progress vec![], vec![], vec![], vec![], vec![], vec![], vec![], ]; let seed = 42; let run1 = run_simulation(seed, &inputs); let run2 = run_simulation(seed, &inputs); assert_eq!( run1.len(), run2.len(), "different number of snapshots: run1={}, run2={}", run1.len(), run2.len() ); for (tick, (s1, s2)) in run1.iter().zip(run2.iter()).enumerate() { assert_eq!( s1, s2, "snapshot at tick {} differs between runs ({} vs {} bytes)", tick, s1.len(), s2.len() ); } } // Note: a `different_seed_produces_different_replay` test is deferred until // the monologue/dialogue systems consume SimRng during the test window. // Currently the proof room with idle inputs doesn't trigger random events, // so different seeds produce identical outputs (correct but untestable).