From bee93963d9fd4cebefafd8eca89888f17fa248d9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 12:04:29 +0100 Subject: [PATCH] test(simulation): cross-room transition scenarios T1-T8 + test suite expansion (#506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/Cargo.lock | 2 +- server/tests/bridge_ipc.rs | 1 + server/tests/bridge_tcp.rs | 1 + server/tests/content_runtime.rs | 6 +- server/tests/content_scaling.rs | 273 ++++++++- server/tests/cross_room_transitions.rs | 637 ++++++++++++++++++++ server/tests/gen_fixtures.rs | 2 + server/tests/golden/proof_room_tick_10.json | 1 + server/tests/golden_suite.rs | 13 +- server/tests/perf_bench.rs | 5 +- server/tests/serialization.rs | 18 +- 11 files changed, 925 insertions(+), 34 deletions(-) create mode 100644 server/tests/cross_room_transitions.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index ae9e05069..40f0aa4bf 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -978,7 +978,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.9" +version = "0.1.10" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index cab7ed4e8..d913aa905 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -60,6 +60,7 @@ fn snapshot_roundtrip_over_unix_socket() { pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], + scan_events: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 9a7063f86..676589b86 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -46,6 +46,7 @@ fn snapshot_roundtrip_over_tcp() { pending_recognitions: vec![], dialogue_response: None, blocked_entities: vec![], + scan_events: vec![], }; bridge diff --git a/server/tests/content_runtime.rs b/server/tests/content_runtime.rs index da603bf85..2f7e16d9f 100644 --- a/server/tests/content_runtime.rs +++ b/server/tests/content_runtime.rs @@ -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"); diff --git a/server/tests/content_scaling.rs b/server/tests/content_scaling.rs index 3ca5bfa5b..9eae0fb75 100644 --- a/server/tests/content_scaling.rs +++ b/server/tests/content_scaling.rs @@ -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 { + let registry = app.world().resource::(); + let player_entity = registry + .to_entity(&StableId(0)) + .expect("player entity at StableId 0"); + match app.world().get::(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 = 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::() + .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::() + .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 = baseline_snap + .entities + .iter() + .filter(|e| e.entity_id <= max_gauntlet_id) + .map(|e| e.entity_id) + .collect(); + let mut stress_ids: Vec = 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 + ); +} diff --git a/server/tests/cross_room_transitions.rs b/server/tests/cross_room_transitions.rs new file mode 100644 index 000000000..3ce803013 --- /dev/null +++ b/server/tests/cross_room_transitions.rs @@ -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::(); + 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::().register(sign); + + // --- Walk: sign appears in buffer --- + run_interaction_system(&mut world); + let interactions = world + .get_mut::(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::(player).unwrap().0 = MovementStance::Sprint; + run_interaction_system(&mut world); + let interactions = world + .get_mut::(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::(player).unwrap().0 = MovementStance::Walk; + run_interaction_system(&mut world); + let interactions = world + .get_mut::(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::().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::().register(item); + + // Pre-transition invariants. + assert!( + world.get::(item).is_none(), + "T2 pre: carried item must not have TilePosition" + ); + assert_eq!( + world.get::(item).unwrap().0, + player_sid, + "T2 pre: CarriedBy must reference player" + ); + + // Simulate room transition: player moves to Crowd Plaza observer position. + *world.get_mut::(player).unwrap() = TilePosition::new(96, 94, 0); + + // Post-transition: item state is unchanged by the player's position change. + assert!( + world.get::(item).is_none(), + "T2 post: item must still have no TilePosition (still carried)" + ); + assert_eq!( + world.get::(item).unwrap().0, + player_sid, + "T2 post: CarriedBy must still reference player after movement" + ); + assert_eq!( + world.get::(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::() + .push(PlayerInput { + tick: 0, + action: PlayerAction::Pause, + }); + app.update(); + + assert_eq!( + app.world().resource::().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::().tick, 0); + + // --- Step 2: MoveNorth while paused (tick 0) — must be discarded --- + app.world_mut() + .resource_mut::() + .push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + app.update(); + + assert_eq!( + *app.world().get::(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::() + .push(PlayerInput { + tick: 0, + action: PlayerAction::Unpause, + }); + app.update(); + + assert_eq!( + app.world().resource::().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::().tick, 1); + + // --- Step 4: MoveNorth after unpause (tick 1) — must be accepted --- + app.world_mut() + .resource_mut::() + .push(PlayerInput { + tick: 1, + action: PlayerAction::MoveNorth, + }); + app.update(); + + assert_eq!( + *app.world().get::(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::().register(npc); + + // Player observes NPC — adds KG entry at Direct confidence. + world + .get_mut::(player) + .unwrap() + .observe_entity(npc_sid, TilePosition::new(40, 112, 0), 0); + + assert!( + world + .get::(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::(player).unwrap() = TilePosition::new(50, 58, 0); + + assert!( + world + .get::(player) + .unwrap() + .knows_entity(&npc_sid), + "T4 post: KG entry must persist after player moves to Hub" + ); + assert_eq!( + world + .get::(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::().register(npc); + + // Player observes NPC — Direct confidence. + world + .get_mut::(player) + .unwrap() + .observe_entity(npc_sid, TilePosition::new(38, 16, 0), 0); + + assert_eq!( + world + .get::(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::(player).unwrap() = TilePosition::new(50, 58, 0); + + // Observation system downgrades confidence (entity left LOS). + world + .get_mut::(player) + .unwrap() + .observe_entity_leaving_los(&npc_sid, 1); + + let kg = world.get::(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::().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::(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::(player).unwrap() = TilePosition::new(78, 37, 0); + run_listening_system(&mut world); + + // Eavesdrop must be cut. + let f = world.get::(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::().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::(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::(player).unwrap() = TilePosition::new(94, 20, 0); + + run_interaction_system(&mut world); + let interactions = world + .get_mut::(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::(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::(player).unwrap() = alcove_pos; + world.get_mut::(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::(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::(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)" + ); +} diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 5eaf8fb5f..2c8bc879c 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -36,6 +36,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> 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", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index aa692bcee..ac0bd2d5d 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -64,6 +64,7 @@ "player_facing": "North", "player_inventory": [], "player_stance": "Sprint", + "scan_events": [], "tick": 8, "version": 9, "visible_tiles": [ diff --git a/server/tests/golden_suite.rs b/server/tests/golden_suite.rs index 36660a971..ea4afba00 100644 --- a/server/tests/golden_suite.rs +++ b/server/tests/golden_suite.rs @@ -308,10 +308,7 @@ fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec { 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)); } diff --git a/server/tests/perf_bench.rs b/server/tests/perf_bench.rs index a11f2f6eb..7fec77779 100644 --- a/server/tests/perf_bench.rs +++ b/server/tests/perf_bench.rs @@ -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()); } diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 766422c7c..6bc7e0d3e 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -25,6 +25,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> 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 = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| { - panic!("deserialize GDScript batch fixture {}: {}", name, e) - }); + let inputs: Vec = 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 }