Files
settled-reach/server/tests/determinism.rs
T
jpmschweitzerandClaude Opus 4.6 b1fdeabb7c test(simulation): sprint 8 test suite — pause guards, registry, boundary, determinism
Add 50+ tests: pause guard suite (movement, unpause, roundtrip, stance,
interact, batch, tick_rate), EntityRegistry lifecycle (stale mapping,
re-register, unknown unregister), boundary value encode/roundtrip (41
values), encoding asymmetry (GDScript signed→Rust unsigned), malformed
batch rejection, determinism gauntlet (20-tick replay), per-fix
determinism unit tests, and recognition monologue integration tests.
Fix pause guard to block all actions except Pause/Unpause while paused.
Fixes #461-463, #466-469, #471-473, #479.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:33 +01:00

349 lines
12 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 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()
);
}
}
// 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).