//! 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 bevy_ecs::prelude::Entity; 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() ); } } /// Different seeds must produce different outputs when the simulation exercises SimRng. /// /// The proof room NPCs don't have DialogueProfile, so Talk alone won't trigger /// dialogue selection (which consumes SimRng). However, monologue content pools /// may fire during idle ticks if ContentPlugin is loaded with matching lines. /// /// This test builds a variant setup with dialogue-capable NPCs and a minimal /// line pool, then sends Talk inputs to exercise the weighted random selection /// path (select_dialogue_line) which consumes SimRng. #[test] fn different_seed_produces_different_replay() { use settled_reach_server::content::line_pool::{ AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation, TrustTier, }; use settled_reach_server::content::LinePoolIndexResource; use settled_reach_server::simulation::dialogue::{ CurrentMood, DialogueCooldownTracker, DialogueProfile, DialogueResponseBuffer, }; /// Build a deterministic app with dialogue-capable NPCs. fn build_app_with_dialogue(seed: u64) -> App { let mut app = build_deterministic_app(seed); // Add DialogueResponseBuffer + DialogueCooldownTracker to the player // (safe: compute_observer_snapshot uses Option<&mut DialogueResponseBuffer>) { let mut q = app .world_mut() .query_filtered::>(); let player = q.single(app.world()).unwrap(); app.world_mut().entity_mut(player).insert(( DialogueResponseBuffer::default(), DialogueCooldownTracker::default(), )); } // Add DialogueProfile + CurrentMood to NPC2 (at 14,18 — visible to player) // NPC2 is the 3rd entity registered (index 2) but we find it by position. { let mut q = app.world_mut().query::<(Entity, &TilePosition)>(); let npc2 = q .iter(app.world()) .find(|(_, pos)| pos.x == 14 && pos.y == 18) .map(|(e, _)| e) .expect("NPC2 at (14,18) should exist"); app.world_mut().entity_mut(npc2).insert(( DialogueProfile { location: "the-terminal".to_string(), role: "dock-worker".to_string(), }, CurrentMood(Mood::Content), )); } // Insert a line pool with multiple lines so weighted selection is non-trivial let mut index = LinePoolIndex::default(); let lines: Vec = (0..10) .map(|i| IndexedDialogueLine { id: format!("test_line_{:03}", i), text: format!("Line variant {}.", i), role: "dock-worker".to_string(), access: vec![AccessTier::Public], trust: TrustTier::Surface, situation: vec![Situation::Routine], topic: vec![], mood: if i % 2 == 0 { vec![Mood::Content] } else { vec![] }, tags: vec![], knowledge_grant: None, }) .collect(); let pool = IndexedDialoguePool { location: "the-terminal".to_string(), role: "dock-worker".to_string(), lines, }; index.dialogue.insert( ("the-terminal".to_string(), "dock-worker".to_string()), pool, ); app.insert_resource(LinePoolIndexResource(index)); app } // Resolve NPC2's StableId for Talk input (it's the 3rd registered entity, sid=2) let npc2_sid = 2u64; let inputs: Vec> = vec![ vec![], // tick 0: idle vec![PlayerInput { tick: 1, action: PlayerAction::Interact { target_entity_id: Some(npc2_sid), verb: Some("Talk".to_string()), }, }], vec![], // tick 2: idle vec![], // tick 3: idle ]; let mut run_a = build_app_with_dialogue(42); let mut run_b = build_app_with_dialogue(9999); let mut snapshots_a = Vec::new(); let mut snapshots_b = Vec::new(); for tick_inputs in &inputs { for app_ref in [&mut run_a, &mut run_b] { let mut queue = app_ref .world_mut() .resource_mut::(); for input in tick_inputs { queue.push(input.clone()); } } run_a.update(); run_b.update(); for (app_ref, snaps) in [(&run_a, &mut snapshots_a), (&run_b, &mut snapshots_b)] { let buffer = app_ref.world().resource::(); if let Some(snapshot) = &buffer.snapshot { let bytes = rmp_serde::to_vec_named(snapshot).expect("serialize snapshot"); snaps.push(bytes); } } } // At least one snapshot should differ between the two seeds let any_different = snapshots_a .iter() .zip(snapshots_b.iter()) .any(|(a, b)| a != b); assert!( any_different, "Different seeds should produce at least one different snapshot when dialogue exercises SimRng" ); }