Files
settled-reach/server/tests/cross_room_transitions.rs
T
jpmschweitzerandClaude Opus 4.7 8eb6c47f74 style(server): clear clippy-1.93 cfg(test)/test-target debt (#967)
Manual clippy-1.93 fixes that the prior machine-applicable sweep couldn't auto-
apply, all in cfg(test) modules and tests/ targets (invisible to the lib-only
pre-push clippy, hence accumulated unflagged):

- disallowed_types HashSet/HashMap → BTreeSet/BTreeMap (determinism rule):
  shadowcast_bench.rs (×8, (i32,i32) keys), mood.rs, sound.rs. SoundEventKind
  gains a PartialOrd/Ord derive (fieldless Copy enum) so it is BTree-usable.
- field_reassign_with_default → struct-init: disclosure.rs, monologue.rs (×2),
  save_io.rs (keeps `mut` for the deliberate last-write-wins overwrite).
- assertions_on_constants on the EAVESDROP_THRESHOLD invariant → compile-time
  `const _: () = assert!(...)`: listening.rs, cross_room_transitions.rs. This is
  stronger than the runtime assert and needs no #[allow].
- approx_constant: settings/types.rs round-trip literal 3.14 → 2.5 (the value is
  arbitrary test data, never meant to be PI — change avoids both the lint and a
  suppression).
- drop_non_drop: vision.rs early Mut<WalkabilityMap> release → scoped block.
- unnecessary_get_then_check → contains_key: information_boundaries.rs (×3).
- cloned_ref_to_slice_refs → std::slice::from_ref: triangle_validation.rs.
- unused_must_use: input.rs dropped the unused .id() on a spawn.

cargo clippy --all-targets -- -D warnings is clean; cargo test green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:03:15 +02:00

647 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 (4, 12 absolute) — standalone scenario position south of the
/// Sprint Gauntlet observer (4, 10 per constants.rs). 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 { seed: 0 });
// 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). Confidence downgrades
/// from Direct to 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.
///
/// NOTE: Uses direct KG API calls (observe_entity, observe_entity_leaving_los)
/// rather than running the full perception system. This isolates the KG
/// persistence contract from perception scheduling.
#[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.
// Compile-time invariant — pins the ordering against a future const edit.
const _: () = assert!(EAVESDROP_THRESHOLD_CAREFUL < EAVESDROP_THRESHOLD);
}