feat(simulation): add Sound Lab, Decay Observatory, Shift Change gauntlet rooms (#505)

Three new gauntlet test rooms for Sprint 13, each targeting a specific
system under test. StableIds are additive-only (66–74).

Sound Lab (66-68): Three NPCs at calibrated tile distances (2, 6, 12
tiles) verify D-018 three-range sound model. Tests confirm Close ≤3,
Medium ≤8, Long ≤20 tile thresholds via SoundEvent::audible_at().

Decay Observatory (69): Single NPC observed in LOS then broken. Tests
confirm D-041 confidence decay: KnowsDetails → KnowsOf → Suspects
(floor), and Stale state when stale_after threshold is exceeded.

Shift Change (70-71): Two NPCs with DailyRoutine components. Test
confirms D-031 phase-boundary check_phase_transition issues PathRequest
only to NPCs not already at their target location.

Sprint 13 reset plates (72-74) wired per existing pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 18:05:46 +01:00
co-authored by Claude Sonnet 4.6
parent 61c452aca4
commit c2de509c01
6 changed files with 735 additions and 3 deletions
@@ -0,0 +1,145 @@
//! Decay Observatory — Room 13 (24x14)
//!
//! Tests D-041 (knowledge graph decay). Observer starts with Direct confidence
//! on an NPC in LOS, then LOS is broken. Tick advances verify confidence
//! degrades: Direct → KnowsDetails → KnowsOf → Suspects → Stale (D-041).
//!
//! Decay runs once per game-minute (every 10 ticks, D-031). Confidence floor
//! is Suspects — decay never removes an entity from the knowledge graph.
//!
//! Layout: Open room. Single observable NPC east of observer.
//! This room sits in the corridor-NW path (x=12-18, y=22-40); the corridor
//! creates natural north/south doorways through the room walls.
//!
//! Observer position: (10, 30) absolute, facing East.
//!
//! Entities (StableId 69):
//! npc_observe_target (16, 30) — 6 tiles east of observer, starts in LOS
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
use crate::simulation::tier::ActiveSim;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 0;
const ORIGIN_Y: i32 = 24;
/// Observer position for Decay Observatory tests (absolute).
pub const OBSERVER_POS: TilePosition = TilePosition { x: 10, y: 30, z: 0 };
/// NPC position — 6 tiles east of observer, starts in direct LOS.
pub const NPC_OBSERVE_POS: TilePosition = TilePosition { x: 16, y: 30, z: 0 };
/// Spawn Decay Observatory entities in canonical order (StableId 69).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
let pos = TilePosition::new(ORIGIN_X + 16, ORIGIN_Y + 6, 0);
let entity = app
.world_mut()
.spawn((
Npc,
ActiveSim,
Interactable,
pos,
Want {
primary: WantKind::Safety,
intensity: 4,
description: "Decay Observatory: observable NPC".to_string(),
},
Contentment { level: 5 },
ToleranceThreshold {
current_stress: 0,
threshold: 60,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::{DecayThresholds, KnowledgeConfidence, KnowledgeState, StableId};
const TARGET: StableId = StableId(1);
fn observe_then_leave(tick_observe: u64, tick_leave: u64) -> KnowledgeGraph {
let mut g = KnowledgeGraph::new();
g.observe_entity(TARGET, NPC_OBSERVE_POS, tick_observe);
g.observe_entity_leaving_los(&TARGET, tick_leave);
g
}
/// After LOS loss, confidence degrades one level per decay pass:
/// KnowsDetails → KnowsOf → Suspects (floor).
#[test]
fn decay_observatory_confidence_degrades_after_los_loss() {
let mut g = observe_then_leave(100, 110);
// Verify starting state: KnowsDetails after LOS loss.
assert_eq!(
g.confidence_of(&TARGET),
Some(KnowledgeConfidence::KnowsDetails),
"confidence must be KnowsDetails immediately after LOS loss"
);
let thresholds = DecayThresholds {
decay_after: 10,
stale_after: 10_000,
};
// Tick 200: age = 200 - 100 = 100 > decay_after=10 → KnowsOf
g.decay(200, &thresholds);
assert_eq!(
g.confidence_of(&TARGET),
Some(KnowledgeConfidence::KnowsOf),
"KnowsDetails must decay to KnowsOf after one decay pass"
);
// Tick 300: age still > decay_after → Suspects
g.decay(300, &thresholds);
assert_eq!(
g.confidence_of(&TARGET),
Some(KnowledgeConfidence::Suspects),
"KnowsOf must decay to Suspects after second decay pass"
);
// Tick 400: Suspects is the floor — no further degradation.
g.decay(400, &thresholds);
assert_eq!(
g.confidence_of(&TARGET),
Some(KnowledgeConfidence::Suspects),
"Suspects is the confidence floor — decay must not go below Suspects"
);
}
/// After exceeding the stale threshold the entry state becomes Stale.
#[test]
fn decay_observatory_stale_after_threshold() {
let mut g = observe_then_leave(100, 110);
let thresholds = DecayThresholds {
decay_after: 10,
stale_after: 50,
};
// Tick 200: age = 200 - 100 = 100 > stale_after=50 → Stale
g.decay(200, &thresholds);
let entry = g.entity_knowledge(&TARGET).unwrap();
assert_eq!(
entry.state,
KnowledgeState::Stale,
"entry state must become Stale when age exceeds stale_after threshold"
);
}
}
+3
View File
@@ -5,6 +5,7 @@
pub mod confrontation_stage;
pub mod crowd_plaza;
pub mod decay_observatory;
pub mod dialogue_room;
pub mod eavesdrop_alcove;
pub mod fog_theater;
@@ -13,4 +14,6 @@ pub mod interaction_gallery;
pub mod inventory_warehouse;
pub mod occlusion_corridor;
pub mod pause_chamber;
pub mod shift_change;
pub mod sound_lab;
pub mod sprint_gauntlet;
+206
View File
@@ -0,0 +1,206 @@
//! Shift Change — Room 14 (16x24)
//!
//! Tests D-031 (day phases) and NPC routine transitions at phase boundaries.
//! Two NPCs have DailyRoutine components with entries for Morning and Afternoon.
//! Advancing game clock to the Afternoon boundary triggers PathRequests for
//! both NPCs (they must move to their Afternoon locations).
//!
//! This room sits adjacent to the corridor-E2 path (x=58-80, y=84-90), which
//! provides natural east-west access through the room interior.
//!
//! Observer position: (72, 90) absolute, facing North.
//!
//! Entities (StableId 70-71):
//! npc_shift_morning (70, 84) — Morning location: 6 tiles north-west of observer
//! npc_shift_afternoon (76, 96) — Afternoon location: 4 tiles east, 6 south of observer
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, DailyRoutine, Npc, RoutineEntry, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::DayPhase;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 64;
const ORIGIN_Y: i32 = 78;
/// Observer position for Shift Change tests (absolute).
pub const OBSERVER_POS: TilePosition = TilePosition { x: 72, y: 90, z: 0 };
/// Morning routine location for both NPCs — where they stand during Morning phase.
pub const MORNING_LOCATION: TilePosition = TilePosition { x: 70, y: 84, z: 0 };
/// Afternoon routine location — where NPCs should walk to at Afternoon boundary.
pub const AFTERNOON_LOCATION: TilePosition = TilePosition { x: 76, y: 96, z: 0 };
/// Spawn Shift Change entities in canonical order (StableId 70-71).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
// npc_shift_morning (StableId 70): starts at Morning location, has both Morning + Afternoon entries.
let entity = app
.world_mut()
.spawn((
Npc,
ActiveSim,
Interactable,
MORNING_LOCATION,
Want {
primary: WantKind::Safety,
intensity: 4,
description: "Shift Change: morning shift NPC".to_string(),
},
Contentment { level: 0 },
ToleranceThreshold {
current_stress: 5,
threshold: 45,
},
MovementSpeed::default(),
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: MORNING_LOCATION,
activity: "Morning station".to_string(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: AFTERNOON_LOCATION,
activity: "Afternoon patrol".to_string(),
},
],
description: "Shifts between morning station and afternoon patrol".to_string(),
},
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
// npc_shift_afternoon (StableId 71): starts at Afternoon location, same routine.
// Starting position differs from Morning location so a transition always triggers a PathRequest.
let entity = app
.world_mut()
.spawn((
Npc,
ActiveSim,
Interactable,
AFTERNOON_LOCATION,
Want {
primary: WantKind::Connection,
intensity: 3,
description: "Shift Change: afternoon shift NPC".to_string(),
},
Contentment { level: 2 },
ToleranceThreshold {
current_stress: 10,
threshold: 55,
},
MovementSpeed::default(),
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: MORNING_LOCATION,
activity: "Morning briefing".to_string(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: AFTERNOON_LOCATION,
activity: "Afternoon post".to_string(),
},
],
description: "Covers afternoon post, returns to morning briefing".to_string(),
},
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::routine::{check_phase_transition, PreviousDayPhase};
use crate::simulation::pathfinding::PathRequest;
use crate::simulation::time::{SimulationTime, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
/// At the Morning→Afternoon boundary, NPCs receive PathRequests for their Afternoon location.
#[test]
fn shift_change_npc_transitions_routine_at_phase_boundary() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<PreviousDayPhase>();
// Spawn npc_shift_morning: currently at MORNING_LOCATION.
// On Afternoon transition it should receive a PathRequest to AFTERNOON_LOCATION.
let npc_morning = world
.spawn((
Npc,
ActiveSim,
MORNING_LOCATION,
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: MORNING_LOCATION,
activity: "Morning station".to_string(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: AFTERNOON_LOCATION,
activity: "Afternoon patrol".to_string(),
},
],
description: "Test routine".to_string(),
},
))
.id();
// Spawn npc_shift_afternoon: currently at AFTERNOON_LOCATION.
// Already at Afternoon location — PathRequest should NOT fire for it.
let npc_afternoon = world
.spawn((
Npc,
ActiveSim,
AFTERNOON_LOCATION,
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: AFTERNOON_LOCATION,
activity: "Afternoon post".to_string(),
}],
description: "Test routine".to_string(),
},
))
.id();
// Advance time to Afternoon boundary (Morning→Afternoon).
world.resource_mut::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_phase_transition);
schedule.run(&mut world);
// npc_shift_morning must have received a PathRequest to AFTERNOON_LOCATION.
let req = world
.get::<PathRequest>(npc_morning)
.expect("npc_shift_morning must receive PathRequest at Afternoon boundary");
assert_eq!(
req.goal, AFTERNOON_LOCATION,
"PathRequest goal must be the Afternoon routine location"
);
// npc_shift_afternoon is already at AFTERNOON_LOCATION — no PathRequest.
assert!(
world.get::<PathRequest>(npc_afternoon).is_none(),
"npc_shift_afternoon already at Afternoon location — must NOT receive PathRequest"
);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Sound Lab — Room 12 (34x20)
//!
//! Tests D-018 (three-range sound model). Sound-emitting entities placed at
//! calibrated Manhattan distances from the observer. Verifies that:
//! - Close (≤3 tiles): SoundEvent is audible at observer
//! - Medium (≤8 tiles): SoundEvent is audible with Medium range but NOT Close
//! - Long (≤20 tiles): SoundEvent is audible with Long range but NOT Medium
//!
//! Layout: Observer at west interior, emitters placed east at increasing
//! distances along the same row.
//!
//! Observer position: (10, 114) absolute, facing East.
//!
//! Entities (StableId 66-68):
//! npc_sound_close (12, 114) — 2 tiles east of observer (Close range, dist ≤3)
//! npc_sound_medium (16, 114) — 6 tiles east of observer (Medium range, dist ≤8)
//! npc_sound_long (22, 114) — 12 tiles east of observer (Long range, dist ≤20)
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
use crate::simulation::tier::ActiveSim;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 0;
const ORIGIN_Y: i32 = 104;
/// Observer position for Sound Lab tests (absolute).
pub const OBSERVER_POS: TilePosition = TilePosition { x: 10, y: 114, z: 0 };
/// Close-range emitter position — 2 tiles east of observer (dist=2, Close ≤3).
pub const CLOSE_EMITTER_POS: TilePosition = TilePosition { x: 12, y: 114, z: 0 };
/// Medium-range emitter position — 6 tiles east of observer (dist=6, Medium ≤8, outside Close).
pub const MEDIUM_EMITTER_POS: TilePosition = TilePosition { x: 16, y: 114, z: 0 };
/// Long-range emitter position — 12 tiles east of observer (dist=12, Long ≤20, outside Medium).
pub const LONG_EMITTER_POS: TilePosition = TilePosition { x: 22, y: 114, z: 0 };
/// NPC definitions: (relative_x, relative_y, want_kind, intensity).
const NPCS: &[(i32, i32, WantKind, u8)] = &[
(12, 10, WantKind::Safety, 3), // npc_sound_close — StableId 66
(16, 10, WantKind::Knowledge, 4), // npc_sound_medium — StableId 67
(22, 10, WantKind::Freedom, 5), // npc_sound_long — StableId 68
];
/// Spawn Sound Lab entities in canonical order (StableId 66-68).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(rx, ry, want_kind, intensity) in NPCS {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((
Npc,
ActiveSim,
Interactable,
pos,
Want {
primary: want_kind,
intensity,
description: "Sound Lab test emitter".to_string(),
},
Contentment { level: 0 },
ToleranceThreshold {
current_stress: 0,
threshold: 50,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::types::SoundRange;
use crate::simulation::sound::{SoundEvent, SoundEventKind};
/// Close emitter is 2 tiles away — must be audible with SoundRange::Close (≤3 tiles, D-018).
#[test]
fn sound_lab_close_range_in_snapshot() {
let event = SoundEvent::at(
&CLOSE_EMITTER_POS,
SoundEventKind::Footstep,
0.8,
SoundRange::Close,
None,
);
assert!(
event.audible_at(&OBSERVER_POS),
"Close emitter at dist=2 must be audible with SoundRange::Close (max 3 tiles)"
);
}
/// Medium emitter is 6 tiles away — audible with Medium (≤8) but NOT with Close (≤3).
#[test]
fn sound_lab_medium_range_indicator_present() {
let medium_event = SoundEvent::at(
&MEDIUM_EMITTER_POS,
SoundEventKind::Voice,
0.6,
SoundRange::Medium,
None,
);
assert!(
medium_event.audible_at(&OBSERVER_POS),
"Medium emitter at dist=6 must be audible with SoundRange::Medium (max 8 tiles)"
);
// Negative: Close range cannot reach 6 tiles.
let close_event = SoundEvent::at(
&MEDIUM_EMITTER_POS,
SoundEventKind::Voice,
0.6,
SoundRange::Close,
None,
);
assert!(
!close_event.audible_at(&OBSERVER_POS),
"Medium-position emitter (dist=6) must NOT be audible with SoundRange::Close (max 3 tiles)"
);
}
/// Long emitter is 12 tiles away — audible with Long (≤20) but NOT with Medium (≤8).
#[test]
fn sound_lab_long_range_insert_only() {
let long_event = SoundEvent::at(
&LONG_EMITTER_POS,
SoundEventKind::Alert,
0.9,
SoundRange::Long,
None,
);
assert!(
long_event.audible_at(&OBSERVER_POS),
"Long emitter at dist=12 must be audible with SoundRange::Long (max 20 tiles)"
);
// Negative: Medium range cannot reach 12 tiles.
let medium_event = SoundEvent::at(
&LONG_EMITTER_POS,
SoundEventKind::Alert,
0.9,
SoundRange::Medium,
None,
);
assert!(
!medium_event.audible_at(&OBSERVER_POS),
"Long-position emitter (dist=12) must NOT be audible with SoundRange::Medium (max 8 tiles)"
);
}
}