Cross-room transition tests (server/tests/cross_room_transitions.rs): - T1: sprint suppresses interaction buffer, restores on Walk (D-055) - T2: CarriedBy survives room transition — no TilePosition leak (D-065) - T3: pause mid-corridor discards movement, Unpause resumes (D-031) - T4: KnowledgeGraph persists across player position change (D-041) - T5: entity knowledge downgrades Direct→KnowsDetails on LOS exit (D-060) - T6: eavesdrop cut immediately on first movement out of corner (D-071) - T7: confrontation verb disappears on retreat beyond MID_RANGE=5 (D-057/D-070) - T8: Sprint blocks eavesdrop accumulation, Careful enables it (D-055+D-071) All 8 tests pass. Test suite grows from 545 → 563 (18 tests added across sprint). Tests use direct ECS World + Schedule pattern; T3 uses full App + SimulationPlugin. Test suite expansion: - content_scaling.rs: max_npc_pack_behavioral_regression + stress tests (#513) - golden/proof_room_tick_10.json: updated golden file for gauntlet world changes - golden_suite.rs, serialization.rs, bridge_ipc.rs, bridge_tcp.rs: adapted to new world entity count and wire types - gen_fixtures.rs, perf_bench.rs, content_runtime.rs: minor test adaptations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
393 lines
13 KiB
Rust
393 lines
13 KiB
Rust
//! Golden file regression test (#485)
|
|
//!
|
|
//! Runs a 10-tick deterministic replay, serializes the final ObserverSnapshot
|
|
//! to JSON, and compares against a committed golden file. Any deviation fails
|
|
//! the test with a field-level diff.
|
|
//!
|
|
//! To regenerate golden files after intentional changes:
|
|
//! UPDATE_GOLDEN=1 cargo test --test golden_suite
|
|
//!
|
|
//! Spec references: D-010 (deterministic simulation), D-030 (testability)
|
|
|
|
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;
|
|
|
|
use bevy_app::prelude::*;
|
|
use serde_json::Value;
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
|
|
const GOLDEN_DIR: &str = "tests/golden";
|
|
const GOLDEN_FILE: &str = "tests/golden/proof_room_tick_10.json";
|
|
const SEED: u64 = 42;
|
|
const NUM_TICKS: usize = 10;
|
|
|
|
/// Build a deterministic simulation app with the proof room.
|
|
/// Mirrors the setup in determinism.rs / main.rs.
|
|
fn build_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);
|
|
app.insert_resource(SimRng::new(seed));
|
|
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);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
{
|
|
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
|
|
}
|
|
|
|
/// Standard 10-tick input sequence for golden file tests.
|
|
/// Matches the first 10 ticks of the determinism test in determinism.rs.
|
|
fn standard_inputs() -> Vec<Vec<PlayerInput>> {
|
|
vec![
|
|
// Tick 0: idle — baseline snapshot
|
|
vec![],
|
|
// Tick 1: move north
|
|
vec![PlayerInput {
|
|
tick: 1,
|
|
action: PlayerAction::MoveNorth,
|
|
}],
|
|
// Tick 2: idle
|
|
vec![],
|
|
// Tick 3: move east
|
|
vec![PlayerInput {
|
|
tick: 3,
|
|
action: PlayerAction::MoveEast,
|
|
}],
|
|
// Tick 4: idle
|
|
vec![],
|
|
// Tick 5: move north
|
|
vec![PlayerInput {
|
|
tick: 5,
|
|
action: PlayerAction::MoveNorth,
|
|
}],
|
|
// Tick 6: stance toggle up
|
|
vec![PlayerInput {
|
|
tick: 6,
|
|
action: PlayerAction::ToggleStanceUp,
|
|
}],
|
|
// Tick 7: move north
|
|
vec![PlayerInput {
|
|
tick: 7,
|
|
action: PlayerAction::MoveNorth,
|
|
}],
|
|
// Tick 8: pause
|
|
vec![PlayerInput {
|
|
tick: 8,
|
|
action: PlayerAction::Pause,
|
|
}],
|
|
// Tick 9: move while paused (should be discarded)
|
|
vec![PlayerInput {
|
|
tick: 9,
|
|
action: PlayerAction::MoveNorth,
|
|
}],
|
|
]
|
|
}
|
|
|
|
/// Recursively sort all object keys for deterministic JSON output.
|
|
fn sort_json_keys(value: &Value) -> Value {
|
|
match value {
|
|
Value::Object(map) => {
|
|
let sorted: BTreeMap<String, Value> = map
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
|
|
.collect();
|
|
Value::Object(sorted.into_iter().collect())
|
|
}
|
|
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
|
|
other => other.clone(),
|
|
}
|
|
}
|
|
|
|
/// Recursive JSON diff — reports all field-level differences with paths.
|
|
fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
|
|
match (expected, actual) {
|
|
(Value::Object(e), Value::Object(a)) => {
|
|
let mut all_keys: Vec<&String> = e.keys().chain(a.keys()).collect();
|
|
all_keys.sort();
|
|
all_keys.dedup();
|
|
for key in all_keys {
|
|
let child = if path.is_empty() {
|
|
format!(".{}", key)
|
|
} else {
|
|
format!("{}.{}", path, key)
|
|
};
|
|
match (e.get(key), a.get(key)) {
|
|
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
|
|
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
|
|
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
|
|
(None, None) => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
(Value::Array(e), Value::Array(a)) => {
|
|
for i in 0..e.len().max(a.len()) {
|
|
let child = format!("{}[{}]", path, i);
|
|
match (e.get(i), a.get(i)) {
|
|
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
|
|
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
|
|
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
|
|
(None, None) => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
if expected != actual {
|
|
diffs.push(format!("{}: expected {}, got {}", path, expected, actual));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn proof_room_tick_10_matches_golden() {
|
|
let mut app = build_app(SEED);
|
|
let inputs = standard_inputs();
|
|
|
|
assert_eq!(inputs.len(), NUM_TICKS);
|
|
|
|
let mut last_snapshot: Option<ObserverSnapshot> = None;
|
|
|
|
for tick_inputs in &inputs {
|
|
{
|
|
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();
|
|
|
|
let buffer = app.world().resource::<SnapshotBuffer>();
|
|
if let Some(snapshot) = &buffer.snapshot {
|
|
last_snapshot = Some(snapshot.clone());
|
|
}
|
|
}
|
|
|
|
let snapshot = last_snapshot.expect("no snapshot produced after 10 ticks");
|
|
|
|
// Serialize to sorted JSON for deterministic comparison
|
|
let actual_value: Value = serde_json::to_value(&snapshot).expect("serialize to JSON");
|
|
let actual_sorted = sort_json_keys(&actual_value);
|
|
let actual_json = serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n";
|
|
|
|
let golden_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE);
|
|
|
|
// UPDATE_GOLDEN=1 mode: write the golden file and return
|
|
if std::env::var("UPDATE_GOLDEN").is_ok() {
|
|
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_DIR);
|
|
std::fs::create_dir_all(&dir).expect("create golden directory");
|
|
std::fs::write(&golden_path, &actual_json).expect("write golden file");
|
|
eprintln!(
|
|
"Golden file written: {} ({} bytes)",
|
|
golden_path.display(),
|
|
actual_json.len()
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Normal mode: compare against golden file
|
|
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
|
|
panic!(
|
|
"Golden file not found: {}. Run with UPDATE_GOLDEN=1 to generate.\nError: {}",
|
|
golden_path.display(),
|
|
e
|
|
)
|
|
});
|
|
let golden_value: Value =
|
|
serde_json::from_str(&golden_json).expect("parse golden file as JSON");
|
|
|
|
let mut diffs = Vec::new();
|
|
diff_json("", &golden_value, &actual_sorted, &mut diffs);
|
|
|
|
if !diffs.is_empty() {
|
|
let mut msg = format!("Golden file mismatch ({} differences):\n", diffs.len());
|
|
for diff in &diffs {
|
|
msg.push_str(&format!(" {}\n", diff));
|
|
}
|
|
msg.push_str(&format!(
|
|
"\nTo update: UPDATE_GOLDEN=1 cargo test --test golden_suite\n\
|
|
Golden file: {}",
|
|
golden_path.display()
|
|
));
|
|
panic!("{}", msg);
|
|
}
|
|
}
|