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>
647 lines
23 KiB
Rust
647 lines
23 KiB
Rust
//! 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);
|
||
}
|