test(simulation): cross-room transition scenarios T1-T8 + test suite expansion (#506)

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>
This commit is contained in:
2026-02-19 12:04:29 +01:00
co-authored by Claude Sonnet 4.6
parent d103e445e7
commit bee93963d9
11 changed files with 925 additions and 34 deletions
+1 -1
View File
@@ -978,7 +978,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.9"
version = "0.1.10"
dependencies = [
"bevy_app",
"bevy_ecs",
+1
View File
@@ -60,6 +60,7 @@ fn snapshot_roundtrip_over_unix_socket() {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
bridge
+1
View File
@@ -46,6 +46,7 @@ fn snapshot_roundtrip_over_tcp() {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
bridge
+3 -3
View File
@@ -142,9 +142,9 @@ fn content_runtime_boot_tick_10_snapshot() {
barrier.wait();
// Server thread must not have panicked
server_handle
.join()
.expect("server thread panicked — content triggered a runtime error during tick processing");
server_handle.join().expect(
"server thread panicked — content triggered a runtime error during tick processing",
);
// Validate final snapshot
let snapshot = last_snapshot.expect("should have received at least one snapshot");
+267 -6
View File
@@ -1,4 +1,4 @@
//! Content scaling test (#500, D-026).
//! Content scaling test (#513, D-026).
//!
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
@@ -6,15 +6,22 @@
//! 1. Tick timing stays within D-026 budget (100ms)
//! 2. Baseline entities still behave identically (deterministic)
//!
//! Sprint 11 adds two new tests (#513 deliverable):
//! - max_npc_pack_tick_budget: 80 NPCs (D-026 Active tier ceiling), 100 ticks,
//! per-tick budget assertion (every tick < 100ms, not just average).
//! - max_npc_pack_behavioral_regression: verifies that adding 46 extra NPCs to
//! hit the Active tier ceiling doesn't change original entity behavior at tick 100.
//!
//! Run with: cargo test --test content_scaling -- --nocapture
use bevy_app::prelude::*;
use std::collections::BTreeMap;
use std::time::Instant;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::BridgePlugin;
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
use settled_reach_server::knowledge::KnowledgePlugin;
use settled_reach_server::knowledge::{KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId};
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
use settled_reach_server::simulation::interaction::Interactable;
use settled_reach_server::simulation::movement::TilePosition;
@@ -30,6 +37,21 @@ const MAX_TICK_MS: f64 = 100.0;
/// Extra NPC counts for scaling tiers.
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
/// D-026 Active tier ceiling: maximum NPCs in full simulation.
const ACTIVE_TIER_NPC_CEILING: usize = 80;
/// Ticks for the full stress test (#513 spec: 100 ticks, 80 NPCs).
const STRESS_TICKS: usize = 100;
/// Known NPC count in the full Gauntlet world (all rooms, Sprint 11 included).
/// Fog Theater: 4, Occlusion Corridor: 4, Inventory Warehouse: 1, Pause Chamber: 1,
/// Dialogue Room: 4, Crowd Plaza: 15, Sprint Gauntlet: 1, Eavesdrop Alcove: 2,
/// Confrontation Stage: 2 = 34 total.
const GAUNTLET_NPC_COUNT: usize = 34;
/// Extra NPCs to spawn on top of the Gauntlet baseline to reach Active tier ceiling.
const STRESS_EXTRA_NPCS: usize = ACTIVE_TIER_NPC_CEILING - GAUNTLET_NPC_COUNT;
/// Set up a Gauntlet world and return the app.
fn setup_baseline() -> App {
let mut app = App::new();
@@ -106,6 +128,25 @@ fn count_entities(app: &App) -> usize {
registry.len() as usize
}
/// Collect the player's KnowledgeGraph confidence levels for all Gauntlet entities
/// (StableIds 0..=max_id). Used to detect KG-level behavioral regression.
#[cfg(feature = "gauntlet")]
fn player_kg_snapshot(app: &App, max_id: u64) -> BTreeMap<u64, KnowledgeConfidence> {
let registry = app.world().resource::<EntityRegistry>();
let player_entity = registry
.to_entity(&StableId(0))
.expect("player entity at StableId 0");
match app.world().get::<KnowledgeGraph>(player_entity) {
Some(kg) => kg
.entities
.iter()
.filter(|(id, _)| id.0 <= max_id)
.map(|(id, entry)| (id.0, entry.confidence))
.collect(),
None => BTreeMap::new(),
}
}
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
#[test]
#[cfg(feature = "gauntlet")]
@@ -144,7 +185,10 @@ fn scaling_tick_timing_within_budget() {
results.push((extra_count, total_entities, avg_ms));
}
eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_MS);
eprintln!(
"\n=== Content Scaling Results (D-026: {}ms budget) ===",
MAX_TICK_MS
);
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
eprintln!("{:-<37}", "");
for &(extra, total, avg_ms) in &results {
@@ -215,7 +259,10 @@ fn extra_npcs_dont_affect_baseline_behavior() {
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
// Same tick
assert_eq!(baseline_snap.tick, scaled_snap.tick, "tick count should match");
assert_eq!(
baseline_snap.tick, scaled_snap.tick,
"tick count should match"
);
// Same game time
assert_eq!(
@@ -224,8 +271,14 @@ fn extra_npcs_dont_affect_baseline_behavior() {
);
// Player position should be identical
let baseline_player = baseline_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
let scaled_player = scaled_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
let baseline_player = baseline_snap
.entities
.iter()
.find(|e| e.kind == EntityKind::Player);
let scaled_player = scaled_snap
.entities
.iter()
.find(|e| e.kind == EntityKind::Player);
assert!(baseline_player.is_some(), "baseline should have player");
assert!(scaled_player.is_some(), "scaled should have player");
@@ -257,3 +310,211 @@ fn extra_npcs_dont_affect_baseline_behavior() {
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
);
}
// =============================================================================
// Sprint 11 / #513 — Max-NPC Pack Stress Tests
// =============================================================================
/// Stress test: Active tier ceiling (80 NPCs), 100 ticks, per-tick budget check.
///
/// Spawns the full Gauntlet baseline ({GAUNTLET_NPC_COUNT} NPCs) plus
/// {STRESS_EXTRA_NPCS} extra NPCs to reach the D-026 Active tier ceiling (80).
/// Runs {STRESS_TICKS} ticks and asserts that EVERY individual tick (not just
/// the average) completes within the 100ms D-026 budget.
///
/// Outputs a PERF_RESULT JSON line compatible with the perf-baseline tooling
/// (same format as tests/perf/baseline.json) so CI can compare against the
/// stored baseline.
#[test]
#[cfg(feature = "gauntlet")]
fn max_npc_pack_tick_budget() {
let mut app = setup_baseline();
spawn_extra_npcs(&mut app, STRESS_EXTRA_NPCS);
let total_entities = count_entities(&app);
// Warm-up: first tick has bevy startup overhead.
app.update();
// Measure STRESS_TICKS, recording each tick individually.
let mut per_tick_us: Vec<u64> = Vec::with_capacity(STRESS_TICKS);
for _ in 0..STRESS_TICKS {
let start = Instant::now();
app.update();
per_tick_us.push(start.elapsed().as_micros() as u64);
}
// --- Statistics ---
let min_us = *per_tick_us.iter().min().unwrap();
let max_us = *per_tick_us.iter().max().unwrap();
let sum: u64 = per_tick_us.iter().sum();
let mean_us = sum / per_tick_us.len() as u64;
let mut sorted = per_tick_us.clone();
sorted.sort_unstable();
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
let p95_us = sorted[p95_idx.min(sorted.len() - 1)];
eprintln!(
"\n=== Max-NPC Pack Stress Test — D-026 tick budget ({} NPCs, {} ticks) ===",
ACTIVE_TIER_NPC_CEILING, STRESS_TICKS
);
eprintln!(
"Entities in world: {} (Gauntlet NPCs: {} extra: {})",
total_entities, GAUNTLET_NPC_COUNT, STRESS_EXTRA_NPCS
);
eprintln!(
"Timing: min={:.3}ms mean={:.3}ms p95={:.3}ms max={:.3}ms budget={}ms",
min_us as f64 / 1000.0,
mean_us as f64 / 1000.0,
p95_us as f64 / 1000.0,
max_us as f64 / 1000.0,
MAX_TICK_MS
);
// Emit PERF_RESULT in the same format as tooling/perf-baseline so output
// can be diffed against tests/perf/baseline.json by CI tooling.
println!(
"PERF_RESULT:{}",
serde_json::json!({
"test": "max_npc_pack_tick_budget",
"spec": "D-026",
"tick_timing": {
"warmup_ticks": 1,
"measured_ticks": STRESS_TICKS,
"min_us": min_us,
"max_us": max_us,
"mean_us": mean_us,
"p95_us": p95_us,
},
"entities": {
"total_in_world": total_entities,
"active_tier_npcs": ACTIVE_TIER_NPC_CEILING,
"gauntlet_npcs": GAUNTLET_NPC_COUNT,
"extra_npcs": STRESS_EXTRA_NPCS,
},
})
);
// Core assertion: EVERY tick must be within the D-026 100ms budget.
// Average-only checks can mask spikes — verify each individual tick.
let budget_us = (MAX_TICK_MS * 1000.0) as u64;
let over_budget: Vec<(usize, u64)> = per_tick_us
.iter()
.enumerate()
.filter(|(_, &us)| us > budget_us)
.map(|(i, &us)| (i, us))
.collect();
assert!(
over_budget.is_empty(),
"D-026 tick budget exceeded with {} NPCs: {} of {} ticks over {}ms\n worst: tick {} at {:.3}ms",
ACTIVE_TIER_NPC_CEILING,
over_budget.len(),
STRESS_TICKS,
MAX_TICK_MS,
over_budget[0].0,
over_budget[0].1 as f64 / 1000.0
);
}
/// Behavioral regression: 80 NPCs must not disturb original entity state at tick 100.
///
/// Runs the pure Gauntlet (GAUNTLET_NPC_COUNT NPCs) and the full 80-NPC stress
/// pack for STRESS_TICKS ticks. Asserts:
/// 1. Snapshot entity IDs for all Gauntlet entities (StableId 0..=65) are identical.
/// 2. Player's KnowledgeGraph confidence entries for Gauntlet entity range are identical.
///
/// This validates D-010 determinism: extra Active-tier NPCs must not affect the
/// simulation of original entities via LOS, KG, or ECS phase ordering.
/// Spec: #513, D-026, D-010.
#[test]
#[cfg(feature = "gauntlet")]
fn max_npc_pack_behavioral_regression() {
use settled_reach_server::test_world::constants::SPRINT11_RESET_PLATE_STABLE_IDS;
// The highest StableId belonging to a Gauntlet entity (Sprint 11 reset plates).
let max_gauntlet_id = SPRINT11_RESET_PLATE_STABLE_IDS.1;
// --- Baseline run: pure Gauntlet, no extra NPCs ---
let mut baseline_app = setup_baseline();
for _ in 0..STRESS_TICKS {
baseline_app.update();
}
let baseline_snapshot = baseline_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let baseline_kg = player_kg_snapshot(&baseline_app, max_gauntlet_id);
// --- Stress run: Gauntlet + extra NPCs to reach 80 NPC Active tier ceiling ---
let mut stress_app = setup_baseline();
spawn_extra_npcs(&mut stress_app, STRESS_EXTRA_NPCS);
for _ in 0..STRESS_TICKS {
stress_app.update();
}
let stress_snapshot = stress_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let stress_kg = player_kg_snapshot(&stress_app, max_gauntlet_id);
let baseline_snap = baseline_snapshot
.expect("baseline Gauntlet should produce a snapshot");
let stress_snap = stress_snapshot
.expect("80-NPC stress run should produce a snapshot");
// Tick index must match (same number of updates).
assert_eq!(
baseline_snap.tick, stress_snap.tick,
"tick count should match between baseline and stress run"
);
// --- 1. Snapshot entity comparison ---
// Collect and sort entity IDs for original Gauntlet entities only.
// Extra NPCs (StableId > max_gauntlet_id) are excluded from comparison.
let mut baseline_ids: Vec<u64> = baseline_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_gauntlet_id)
.map(|e| e.entity_id)
.collect();
let mut stress_ids: Vec<u64> = stress_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_gauntlet_id)
.map(|e| e.entity_id)
.collect();
baseline_ids.sort_unstable();
stress_ids.sort_unstable();
assert_eq!(
baseline_ids, stress_ids,
"Gauntlet entity visibility at tick {} must be identical: baseline {} entities vs {} with {} extra NPCs",
STRESS_TICKS,
baseline_ids.len(),
stress_ids.len(),
STRESS_EXTRA_NPCS
);
// --- 2. Knowledge graph comparison ---
// Player's KG confidence levels for Gauntlet entities (StableId 0..=max_gauntlet_id)
// must be identical in both runs. Extra NPCs in the hub may be added to the
// player's KG (higher StableIds), but must not affect original entity entries.
assert_eq!(
baseline_kg, stress_kg,
"Player KG confidence entries for Gauntlet entities (id <= {}) differ at tick {}\n baseline: {} entries stress: {} entries",
max_gauntlet_id,
STRESS_TICKS,
baseline_kg.len(),
stress_kg.len()
);
eprintln!(
"Behavioral regression PASS: {} Gauntlet entities identical at tick {} ({} NPCs vs {} NPCs)",
baseline_ids.len(),
STRESS_TICKS,
GAUNTLET_NPC_COUNT,
ACTIVE_TIER_NPC_CEILING
);
}
+637
View File
@@ -0,0 +1,637 @@
//! Cross-room transition test scenarios (T1-T8)
//!
//! Sprint 11 (#506) — system-combination tests at room boundaries.
//! Each test exercises a bug class that emerges when two subsystems interact
//! across a coordinate boundary (simulated by player position change).
//!
//! Tests use the ECS world setup pattern with direct system schedule execution
//! — no ad-hoc test harness per sprint requirement.
//!
//! Decision refs:
//! D-055 — sprint suppresses interaction buffer (T1, T8)
//! D-065 — 9-slot inventory, CarriedBy component (T2)
//! D-031 — pause/unpause, TickRate guard (T3)
//! D-041 — KnowledgeGraph persistence across transitions (T4, T5)
//! D-060 — cognitive delay, entity recognition persistence (T5)
//! D-071 — ListeningFocus eavesdrop positioning (T6, T8)
//! D-070 — confrontation as cognitive vulnerability, verb range (T7)
//! D-057 — verb computation, interaction range transitions (T7)
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::Schedule;
use settled_reach_server::bridge::types::{MovementStance, VerbKind};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::npc::Npc;
use settled_reach_server::simulation::interaction::{
compute_nearby_interactions, Interactable, NearbyInteractionBuffer, ObjectType,
};
use settled_reach_server::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use settled_reach_server::simulation::listening::{
update_listening_focus, ListeningFocus, EAVESDROP_THRESHOLD, EAVESDROP_THRESHOLD_CAREFUL,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::stance::Stance;
// ---------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------
/// Minimal world with EntityRegistry (no App — single-system schedule tests).
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
fn run_interaction_system(world: &mut World) {
let mut sched = Schedule::default();
sched.add_systems(compute_nearby_interactions);
sched.run(world);
}
fn run_listening_system(world: &mut World) {
let mut sched = Schedule::default();
sched.add_systems(update_listening_focus);
sched.run(world);
}
// ---------------------------------------------------------
// T1: Sprint Exit — buffer clears during sprint, restores on Walk
// D-055, Sprint Gauntlet room
// ---------------------------------------------------------
/// T1 — Sprint Exit.
///
/// Player at Sprint Gauntlet observer position (4, 12 absolute) with Walk
/// stance. A Readable sign at (6, 12) is within CLOSE_RANGE (distance 2).
///
/// During Walk: sign appears in interaction buffer.
/// During Sprint: buffer is empty (D-055 suppression).
/// After stance returns to Walk: buffer repopulates within one compute cycle.
///
/// This covers the cross-room exit behaviour: player sprinting out of the
/// Sprint Gauntlet loses all interaction context while in sprint.
#[test]
fn t1_sprint_suppresses_buffer_and_restores_on_walk() {
let mut world = setup_world();
// Player at Sprint Gauntlet observer absolute position (ORIGIN_X=0, ORIGIN_Y=2,
// rel observer (4,10) → abs (4,12)), Walk stance.
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(4, 12, 0),
NearbyInteractionBuffer::default(),
Stance(MovementStance::Walk),
))
.id();
// Readable sign at (6, 12) abs — distance 2 from player (CLOSE_RANGE=2).
// Mirrors sprint_gauntlet.rs sign entity (StableId 56).
let sign = world
.spawn((
TilePosition::new(6, 12, 0),
Interactable,
ObjectType::Readable,
))
.id();
world.resource_mut::<EntityRegistry>().register(sign);
// --- Walk: sign appears in buffer ---
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert!(
!interactions.is_empty(),
"T1 Walk: sign at distance 2 must appear in interaction buffer"
);
// --- Sprint: buffer suppressed (D-055) ---
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Sprint;
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert!(
interactions.is_empty(),
"T1 Sprint: interaction buffer must be empty (D-055 sprint suppression)"
);
// --- Walk again: buffer repopulates within one compute cycle ---
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Walk;
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert!(
!interactions.is_empty(),
"T1 Walk after Sprint: interaction buffer must repopulate"
);
}
// ---------------------------------------------------------
// T2: Inventory Carry — CarriedBy survives room transition
// D-065, Inventory Warehouse → Crowd Plaza
// ---------------------------------------------------------
/// T2 — Inventory Interact.
///
/// Player picks up an item (CarriedBy set, TilePosition removed).
/// Player moves to a new coordinate region (simulates room transition).
///
/// Asserts: CarriedBy still references player, item has no TilePosition.
/// The information boundary (D-010 principle 2) holds across coordinates:
/// a carried item is never "in" the new room until explicitly placed.
#[test]
fn t2_carried_item_survives_room_transition() {
let mut world = setup_world();
// Player at Inventory Warehouse observer position (abs 17, 54).
let player = world
.spawn((PlayerCharacter, TilePosition::new(17, 54, 0)))
.id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Item already in inventory (no TilePosition — it has been taken).
let item = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
world.resource_mut::<EntityRegistry>().register(item);
// Pre-transition invariants.
assert!(
world.get::<TilePosition>(item).is_none(),
"T2 pre: carried item must not have TilePosition"
);
assert_eq!(
world.get::<CarriedBy>(item).unwrap().0,
player_sid,
"T2 pre: CarriedBy must reference player"
);
// Simulate room transition: player moves to Crowd Plaza observer position.
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(96, 94, 0);
// Post-transition: item state is unchanged by the player's position change.
assert!(
world.get::<TilePosition>(item).is_none(),
"T2 post: item must still have no TilePosition (still carried)"
);
assert_eq!(
world.get::<CarriedBy>(item).unwrap().0,
player_sid,
"T2 post: CarriedBy must still reference player after movement"
);
assert_eq!(
world.get::<InventorySlot>(item).unwrap().0,
0,
"T2 post: InventorySlot must be unchanged after room transition"
);
}
// ---------------------------------------------------------
// T3: Pause Anywhere — mid-corridor pause discards movement
// D-031
// ---------------------------------------------------------
/// T3 — Pause Anywhere.
///
/// Player at a corridor position between two rooms (corridor-N midpoint
/// between Hub and Fog Theater, ~abs (50, 39)).
/// Pause → movement discarded. Unpause → movement accepted.
///
/// Tests that pause state is position-agnostic: the pause guard fires
/// regardless of whether the player is inside a room or between rooms.
#[test]
fn t3_pause_mid_corridor_discards_movement_and_resumes() {
use bevy_app::prelude::*;
use settled_reach_server::bridge::types::{PlayerAction, PlayerInput};
use settled_reach_server::simulation::input::InputQueue;
use settled_reach_server::simulation::time::{SimulationTime, TickRate};
use settled_reach_server::simulation::SimulationPlugin;
let mut app = App::new();
app.add_plugins(SimulationPlugin);
// 200×200 walkability map covers the full gauntlet coordinate space.
app.insert_resource(WalkabilityMap::new(200, 200, 1));
// Player at corridor-N midpoint (between Hub at y≈46 and Fog Theater at y≈2).
let player = app
.world_mut()
.spawn((PlayerCharacter, TilePosition::new(50, 39, 0)))
.id();
// --- Step 1: Pause (tick 0) ---
app.world_mut()
.resource_mut::<InputQueue>()
.push(PlayerInput {
tick: 0,
action: PlayerAction::Pause,
});
app.update();
assert_eq!(
app.world().resource::<SimulationTime>().tick_rate,
TickRate::Paused,
"T3 step 1: game must be paused"
);
// Paused — advance_tick does not fire; tick stays at 0.
assert_eq!(app.world().resource::<SimulationTime>().tick, 0);
// --- Step 2: MoveNorth while paused (tick 0) — must be discarded ---
app.world_mut()
.resource_mut::<InputQueue>()
.push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
app.update();
assert_eq!(
*app.world().get::<TilePosition>(player).unwrap(),
TilePosition::new(50, 39, 0),
"T3 step 2: player position must be unchanged while paused"
);
// --- Step 3: Unpause (tick 0) ---
app.world_mut()
.resource_mut::<InputQueue>()
.push(PlayerInput {
tick: 0,
action: PlayerAction::Unpause,
});
app.update();
assert_eq!(
app.world().resource::<SimulationTime>().tick_rate,
TickRate::Full,
"T3 step 3: game must be running after Unpause"
);
// advance_tick fires for the first time (Full rate): tick 0 → 1.
assert_eq!(app.world().resource::<SimulationTime>().tick, 1);
// --- Step 4: MoveNorth after unpause (tick 1) — must be accepted ---
app.world_mut()
.resource_mut::<InputQueue>()
.push(PlayerInput {
tick: 1,
action: PlayerAction::MoveNorth,
});
app.update();
assert_eq!(
*app.world().get::<TilePosition>(player).unwrap(),
TilePosition::new(50, 38, 0),
"T3 step 4: player must move north (y-1) after unpause"
);
}
// ---------------------------------------------------------
// T4: Knowledge Graph Persistence across room transition
// D-041
// ---------------------------------------------------------
/// T4 — Knowledge State Persistence.
///
/// Player observes NPC in Dialogue Room (adds KG entry at Direct confidence).
/// Player moves to Hub (simulates room transition).
///
/// Asserts: KG entry persists. The KnowledgeGraph component is not cleared
/// or invalidated by a change in player TilePosition.
#[test]
fn t4_knowledge_graph_survives_room_transition() {
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(50, 114, 0), // Dialogue Room observer position
KnowledgeGraph::new(),
))
.id();
// NPC in Dialogue Room (npc_stranger at abs ~(40, 112)).
let npc = world.spawn(TilePosition::new(40, 112, 0)).id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
// Player observes NPC — adds KG entry at Direct confidence.
world
.get_mut::<KnowledgeGraph>(player)
.unwrap()
.observe_entity(npc_sid, TilePosition::new(40, 112, 0), 0);
assert!(
world
.get::<KnowledgeGraph>(player)
.unwrap()
.knows_entity(&npc_sid),
"T4 pre: player must know NPC before room transition"
);
// Simulate room transition: player moves to Hub observer position.
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(50, 58, 0);
assert!(
world
.get::<KnowledgeGraph>(player)
.unwrap()
.knows_entity(&npc_sid),
"T4 post: KG entry must persist after player moves to Hub"
);
assert_eq!(
world
.get::<KnowledgeGraph>(player)
.unwrap()
.entity_count(),
1,
"T4 post: exactly 1 KG entry after room transition"
);
}
// ---------------------------------------------------------
// T5: Entity Knowledge Downgrades on LOS Exit (Fog Carry-Over)
// D-041, D-060
// ---------------------------------------------------------
/// T5 — Fog Carry-Over.
///
/// Player observes NPC in Fog Theater at Direct confidence.
/// Player moves to Hub (NPC now out of LOS). Observation system simulates
/// the confidence downgrade: Direct → KnowsDetails.
///
/// Asserts: KG entry persists (entity is remembered, not erased).
/// Server-side "fog carry-over" means previously-seen entities remain in
/// the KG at reduced confidence so the client can render a "last seen"
/// fog state rather than a clean erasure.
#[test]
fn t5_entity_knowledge_downgrades_on_los_exit_not_erased() {
use settled_reach_server::knowledge::types::KnowledgeConfidence;
let mut world = setup_world();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(56, 18, 0), // Fog Theater observer position
KnowledgeGraph::new(),
))
.id();
// NPC in Fog Theater (npc_fog_near at abs (38, 16)).
let npc = world.spawn(TilePosition::new(38, 16, 0)).id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
// Player observes NPC — Direct confidence.
world
.get_mut::<KnowledgeGraph>(player)
.unwrap()
.observe_entity(npc_sid, TilePosition::new(38, 16, 0), 0);
assert_eq!(
world
.get::<KnowledgeGraph>(player)
.unwrap()
.confidence_of(&npc_sid),
Some(KnowledgeConfidence::Direct),
"T5 pre: NPC must be at Direct confidence while player is in Fog Theater"
);
// Player moves to Hub — NPC is now out of LOS.
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(50, 58, 0);
// Observation system downgrades confidence (entity left LOS).
world
.get_mut::<KnowledgeGraph>(player)
.unwrap()
.observe_entity_leaving_los(&npc_sid, 1);
let kg = world.get::<KnowledgeGraph>(player).unwrap();
assert!(
kg.knows_entity(&npc_sid),
"T5 post: KG entry must persist after player leaves the room (fog carry-over)"
);
assert_eq!(
kg.confidence_of(&npc_sid),
Some(KnowledgeConfidence::KnowsDetails),
"T5 post: confidence must downgrade from Direct to KnowsDetails on LOS exit"
);
}
// ---------------------------------------------------------
// T6: Eavesdrop Cut on Player Movement
// D-071, Eavesdrop Alcove
// ---------------------------------------------------------
/// T6 — Eavesdrop Cut on Transition.
///
/// Player is stationary at the Eavesdrop Alcove corner position
/// (abs 78, 36) with an active eavesdrop_target. Player moves one step
/// south (leaving the eavesdrop position). Asserts: stationary_ticks resets
/// to 0 and eavesdrop_target clears.
///
/// This prevents eavesdrop state leaking when the player walks out of the
/// Eavesdrop Alcove: the very first movement cuts the focus.
#[test]
fn t6_eavesdrop_cut_on_player_movement() {
let mut world = setup_world();
// NPC speaker A from Eavesdrop Alcove (StableId 58, abs 80, 32).
let speaker_a = world.spawn(TilePosition::new(80, 32, 0)).id();
let speaker_a_sid = world.resource_mut::<EntityRegistry>().register(speaker_a);
// Player at eavesdrop corner position with active eavesdrop focus.
let corner_pos = TilePosition::new(78, 36, 0);
let mut focus = ListeningFocus::new(corner_pos);
focus.stationary_ticks = EAVESDROP_THRESHOLD + 10;
focus.eavesdrop_target = Some(speaker_a_sid);
let player = world
.spawn((
PlayerCharacter,
corner_pos,
focus,
Stance(MovementStance::Careful), // Careful stance for eavesdrop
))
.id();
// Pre-move: eavesdrop is active.
{
let f = world.get::<ListeningFocus>(player).unwrap();
assert!(
f.eavesdrop_target.is_some(),
"T6 pre: eavesdrop_target must be set before movement"
);
assert!(
f.stationary_ticks > EAVESDROP_THRESHOLD,
"T6 pre: stationary_ticks must exceed threshold"
);
}
// Player moves one step south (leaving eavesdrop corner).
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(78, 37, 0);
run_listening_system(&mut world);
// Eavesdrop must be cut.
let f = world.get::<ListeningFocus>(player).unwrap();
assert_eq!(
f.stationary_ticks, 0,
"T6 post: stationary_ticks must reset to 0 on movement"
);
assert!(
f.eavesdrop_target.is_none(),
"T6 post: eavesdrop_target must clear when player leaves eavesdrop position"
);
}
// ---------------------------------------------------------
// T7: Confrontation Interrupt on Room Exit
// D-070, D-057, Confrontation Stage
// ---------------------------------------------------------
/// T7 — Confrontation Interrupt.
///
/// Player at CLOSE_RANGE (distance 2) from NPC target in Confrontation Stage:
/// Talk verb is available (confrontation is possible at this range).
/// Player retreats to observer position (94, 20) — distance 8, beyond MID_RANGE=5:
/// all NPC verbs disappear from the interaction buffer.
///
/// This models the confrontation "interrupt" when the player moves away —
/// the verb set changes, ending the potential confrontation.
#[test]
fn t7_confrontation_verb_disappears_on_retreat_beyond_mid_range() {
let mut world = setup_world();
// NPC target at Confrontation Stage absolute position (94, 12).
let npc = world
.spawn((Npc, TilePosition::new(94, 12, 0), Interactable))
.id();
world.resource_mut::<EntityRegistry>().register(npc);
// Step 1: Player at (94, 14) — distance 2 from NPC (CLOSE_RANGE=2).
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(94, 14, 0),
NearbyInteractionBuffer::default(),
Stance(MovementStance::Walk),
))
.id();
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert_eq!(
interactions.len(),
1,
"T7 close: NPC at distance 2 must appear in interaction buffer"
);
assert!(
interactions[0].verbs.iter().any(|v| v.kind == VerbKind::Talk),
"T7 close: Talk must be available at CLOSE_RANGE (confrontation possible)"
);
// Step 2: Player retreats to observer position (94, 20) — distance 8.
// MID_RANGE = 5; distance 8 is fully out of range.
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(94, 20, 0);
run_interaction_system(&mut world);
let interactions = world
.get_mut::<NearbyInteractionBuffer>(player)
.unwrap()
.take();
assert!(
interactions.is_empty(),
"T7 retreat: NPC at distance 8 must not appear in buffer (beyond MID_RANGE=5)"
);
}
// ---------------------------------------------------------
// T8: Sprint Blocks Eavesdrop Accumulation (D-055 + D-071)
// Sprint Gauntlet → Eavesdrop Alcove transition
// ---------------------------------------------------------
/// T8 — Sprint + Eavesdrop Cross-System Interaction.
///
/// Sprint stance (D-055 high-alert) prevents stationary_ticks from
/// accumulating in ListeningFocus (D-071), so eavesdrop cannot activate
/// while the player is sprinting.
///
/// Scenario: Player sprints through Sprint Gauntlet — 50 ticks stationary
/// in Sprint stance → stationary_ticks stays at 0. Player then moves to
/// Eavesdrop Alcove and switches to Careful stance. After
/// EAVESDROP_THRESHOLD_CAREFUL stationary ticks, the threshold is met.
///
/// This catches the cross-system bug: stale sprint state leaking into the
/// eavesdrop counter if the Sprint check in update_listening_focus is absent.
#[test]
fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() {
let mut world = setup_world();
// Player starts at Sprint Gauntlet observer position with Sprint stance.
let sprint_pos = TilePosition::new(4, 12, 0); // abs (4,12)
let player = world
.spawn((
PlayerCharacter,
sprint_pos,
ListeningFocus::new(sprint_pos),
Stance(MovementStance::Sprint),
))
.id();
// 50 stationary ticks at Sprint — counter must not accumulate.
for _ in 0..50 {
run_listening_system(&mut world);
}
assert_eq!(
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
0,
"T8 sprint: Sprint must block stationary_ticks (50 ticks at sprint, still 0)"
);
// Transition: player moves to Eavesdrop Alcove, switches to Careful stance.
let alcove_pos = TilePosition::new(78, 36, 0);
*world.get_mut::<TilePosition>(player).unwrap() = alcove_pos;
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Careful;
// One tick to register the movement: system detects position change,
// resets stationary_ticks to 0, and updates last_position to alcove_pos.
run_listening_system(&mut world);
assert_eq!(
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
0,
"T8 transition: movement tick must reset stationary_ticks to 0"
);
// EAVESDROP_THRESHOLD_CAREFUL stationary ticks in Careful stance.
for _ in 0..EAVESDROP_THRESHOLD_CAREFUL {
run_listening_system(&mut world);
}
{
let f = world.get::<ListeningFocus>(player).unwrap();
assert_eq!(
f.stationary_ticks, EAVESDROP_THRESHOLD_CAREFUL,
"T8 careful: stationary_ticks must accumulate cleanly after stance change"
);
assert!(
f.stationary_ticks >= EAVESDROP_THRESHOLD_CAREFUL,
"T8 careful: stationary_ticks must meet Careful eavesdrop threshold"
);
}
// Verify D-071 invariant: Careful threshold is strictly less than normal.
assert!(
EAVESDROP_THRESHOLD_CAREFUL < EAVESDROP_THRESHOLD,
"T8: Careful threshold must be < normal threshold (D-071 invariant)"
);
}
+2
View File
@@ -36,6 +36,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
}
}
@@ -206,6 +207,7 @@ fn generate_msgpack_fixtures() {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
write_fixture(
"snapshot_v2_full",
@@ -64,6 +64,7 @@
"player_facing": "North",
"player_inventory": [],
"player_stance": "Sprint",
"scan_events": [],
"tick": 8,
"version": 9,
"visible_tiles": [
+3 -10
View File
@@ -308,10 +308,7 @@ fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<Strin
}
_ => {
if expected != actual {
diffs.push(format!(
"{}: expected {}, got {}",
path, expected, actual
));
diffs.push(format!("{}: expected {}, got {}", path, expected, actual));
}
}
}
@@ -349,8 +346,7 @@ fn proof_room_tick_10_matches_golden() {
// 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 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);
@@ -382,10 +378,7 @@ fn proof_room_tick_10_matches_golden() {
diff_json("", &golden_value, &actual_sorted, &mut diffs);
if !diffs.is_empty() {
let mut msg = format!(
"Golden file mismatch ({} differences):\n",
diffs.len()
);
let mut msg = format!("Golden file mismatch ({} differences):\n", diffs.len());
for diff in &diffs {
msg.push_str(&format!(" {}\n", diff));
}
+1 -4
View File
@@ -215,8 +215,5 @@ fn perf_tick_timing() {
},
});
println!(
"PERF_RESULT:{}",
serde_json::to_string(&result).unwrap()
);
println!("PERF_RESULT:{}", serde_json::to_string(&result).unwrap());
}
+8 -10
View File
@@ -25,6 +25,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
}
}
@@ -252,6 +253,7 @@ fn snapshot_v2_fields_roundtrip() {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -345,6 +347,7 @@ fn all_facing_direction_variants_roundtrip() {
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
scan_events: vec![],
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
@@ -1040,18 +1043,16 @@ fn gdscript_generated_fixtures_deserialize() {
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
if name.starts_with("input_batch") {
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| {
panic!("deserialize GDScript batch fixture {}: {}", name, e)
});
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&bytes)
.unwrap_or_else(|e| panic!("deserialize GDScript batch fixture {}: {}", name, e));
assert!(
!inputs.is_empty(),
"batch fixture {} should not be empty",
name
);
} else if name.starts_with("input_") || name.starts_with("boundary_tick_") {
let input: PlayerInput = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| {
panic!("deserialize GDScript input fixture {}: {}", name, e)
});
let input: PlayerInput = rmp_serde::from_slice(&bytes)
.unwrap_or_else(|e| panic!("deserialize GDScript input fixture {}: {}", name, e));
// Verify specific fixtures for extra confidence
match name.as_str() {
"input_move_north" => {
@@ -1078,10 +1079,7 @@ fn gdscript_generated_fixtures_deserialize() {
assert_eq!(input.tick, 65536, "int_32 asymmetry: tick=65536");
}
"boundary_tick_2147483647" => {
assert_eq!(
input.tick, 2147483647,
"int_32 asymmetry: tick=2^31-1"
);
assert_eq!(input.tick, 2147483647, "int_32 asymmetry: tick=2^31-1");
}
_ => {} // Other fixtures: deserialization success is sufficient
}