Files
settled-reach/server/tests/determinism.rs
T
jpmschweitzerandClaude Sonnet 4.6 ce0df2f320 refactor(simulation): remove v0.1 content loading system (#655)
Delete the hand-authored YAML content pipeline (server/src/content/) superseded
by the v0.2 generator-first approach (D-122, D-128). Runtime ECS types that were
co-located with content loading have been extracted to dedicated simulation modules:

- simulation/triangle.rs: TriangleState, TriangleCrisisEventQueue, tick/resolve systems
- simulation/line_pool.rs: LinePoolIndex, AccessTier, TrustTier, Mood, LinePoolIndexResource
- simulation/knowledge_grant.rs: KnowledgeGrant, Prerequisites

Monologue systems (trigger_monologue, trigger_recognition_monologue,
trigger_event_monologue) now use hardcoded fallback lines only; the
ContentStoreResource branch and select_pool_line function are removed.

Deleted: content/{loader,types,line_pool,hot_reload,spawn,instantiation,entanglement,mod}.rs
Deleted: tests/{content_loading,content_runtime,content_scaling,template_instantiation,template_schema}.rs
Deleted: bin/line_preview.rs (v0.1 tool)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:07:57 +01:00

484 lines
17 KiB
Rust

//! 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::<WalkabilityMap>();
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::<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
}
/// 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<PlayerInput>]) -> Vec<Vec<u8>> {
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::<settled_reach_server::simulation::input::InputQueue>();
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::<SnapshotBuffer>();
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<PlayerInput>> = 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::simulation::line_pool::{
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
TrustTier, 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::<Entity, bevy_ecs::query::With<PlayerCharacter>>();
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<IndexedDialogueLine> = (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<PlayerInput>> = 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::<settled_reach_server::simulation::input::InputQueue>();
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::<SnapshotBuffer>();
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"
);
}