Schema and Rust Mood enum renamed for author-friendly vocabulary: fond→warm, comfortable→content, worried→anxious, concerned→frustrated. Dropped: analytical (merged into focused), conflicted (modeled as suspicious+warm collision). Added: hostile. Final 8 moods: anxious, frustrated, content, suspicious, warm, hostile, relieved, focused. Neutral = untagged. Resolves Gestalt's blocking issue on #121. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
485 lines
17 KiB
Rust
485 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::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::<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"
|
|
);
|
|
}
|