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
+77 -2
View File
@@ -200,6 +200,61 @@ pub const CONFRONTATION_STAGE: GauntletRoom = GauntletRoom {
}),
};
/// Sound Lab — Room 12 (34x20)
/// Tests D-018 (three-range sound model). Sound-emitting NPCs at calibrated
/// distances from the observer: Close (2 tiles), Medium (6 tiles), Long (12 tiles).
pub const SOUND_LAB: GauntletRoom = GauntletRoom {
name: "sound_lab",
origin: TilePosition {
x: 0,
y: 104,
z: 0,
},
size: (34, 20),
spawn: TilePosition {
x: 10,
y: 114,
z: 0,
},
observer: TilePosition {
x: 10,
y: 114,
z: 0,
},
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition {
x: 16,
y: 103,
z: 0,
}),
};
/// Decay Observatory — Room 13 (24x14)
/// Tests D-041 knowledge graph decay. NPC observed in LOS then LOS broken;
/// confidence degrades Direct → KnowsDetails → KnowsOf → Suspects over ticks.
pub const DECAY_OBSERVATORY: GauntletRoom = GauntletRoom {
name: "decay_observatory",
origin: TilePosition { x: 0, y: 24, z: 0 },
size: (24, 14),
spawn: TilePosition { x: 10, y: 30, z: 0 },
observer: TilePosition { x: 10, y: 30, z: 0 },
observer_facing: Facing(FacingDirection::East),
reset_plate: Some(TilePosition { x: 15, y: 23, z: 0 }),
};
/// Shift Change — Room 14 (16x24)
/// Tests D-031 day-phase transitions. Two NPCs with DailyRoutine components
/// receive PathRequests when game clock crosses a phase boundary.
pub const SHIFT_CHANGE: GauntletRoom = GauntletRoom {
name: "shift_change",
origin: TilePosition { x: 64, y: 78, z: 0 },
size: (16, 24),
spawn: TilePosition { x: 72, y: 90, z: 0 },
observer: TilePosition { x: 72, y: 90, z: 0 },
observer_facing: Facing(FacingDirection::North),
reset_plate: Some(TilePosition { x: 72, y: 78, z: 0 }),
};
/// All rooms in canonical spawn order.
/// THIS ORDER DETERMINES STABLEID ASSIGNMENT.
/// Do not reorder existing entries. Append new rooms at the end.
@@ -215,6 +270,9 @@ pub const ROOMS: &[GauntletRoom] = &[
SPRINT_GAUNTLET,
EAVESDROP_ALCOVE,
CONFRONTATION_STAGE,
SOUND_LAB,
DECAY_OBSERVATORY,
SHIFT_CHANGE,
];
/// Look up which room a position falls in.
@@ -259,6 +317,12 @@ pub const EAVESDROP_ALCOVE_STABLE_IDS: (u64, u64) = (58, 60);
pub const CONFRONTATION_STAGE_STABLE_IDS: (u64, u64) = (61, 62);
/// Reset plates for Sprint 11 rooms (sprint_gauntlet, eavesdrop_alcove, confrontation_stage).
pub const SPRINT11_RESET_PLATE_STABLE_IDS: (u64, u64) = (63, 65);
// Sprint 13 rooms — appended after SPRINT11_RESET_PLATE_STABLE_IDS per additive-only rule.
pub const SOUND_LAB_STABLE_IDS: (u64, u64) = (66, 68);
pub const DECAY_OBSERVATORY_STABLE_IDS: (u64, u64) = (69, 69);
pub const SHIFT_CHANGE_STABLE_IDS: (u64, u64) = (70, 71);
/// Reset plates for Sprint 13 rooms (sound_lab, decay_observatory, shift_change).
pub const SPRINT13_RESET_PLATE_STABLE_IDS: (u64, u64) = (72, 74);
/// Number of actively-spawned entities in the current Gauntlet build.
/// Derived from StableId ranges of all rooms + player + reset plates.
@@ -275,7 +339,11 @@ pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
+ (SPRINT_GAUNTLET_STABLE_IDS.1 - SPRINT_GAUNTLET_STABLE_IDS.0 + 1) as usize
+ (EAVESDROP_ALCOVE_STABLE_IDS.1 - EAVESDROP_ALCOVE_STABLE_IDS.0 + 1) as usize
+ (CONFRONTATION_STAGE_STABLE_IDS.1 - CONFRONTATION_STAGE_STABLE_IDS.0 + 1) as usize
+ (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize;
+ (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize
+ (SOUND_LAB_STABLE_IDS.1 - SOUND_LAB_STABLE_IDS.0 + 1) as usize
+ (DECAY_OBSERVATORY_STABLE_IDS.1 - DECAY_OBSERVATORY_STABLE_IDS.0 + 1) as usize
+ (SHIFT_CHANGE_STABLE_IDS.1 - SHIFT_CHANGE_STABLE_IDS.0 + 1) as usize
+ (SPRINT13_RESET_PLATE_STABLE_IDS.1 - SPRINT13_RESET_PLATE_STABLE_IDS.0 + 1) as usize;
#[cfg(test)]
mod tests {
@@ -384,7 +452,7 @@ mod tests {
#[test]
fn all_rooms_in_correct_order() {
assert_eq!(ROOMS.len(), 11);
assert_eq!(ROOMS.len(), 14);
assert_eq!(ROOMS[0].name, "central_hub");
assert_eq!(ROOMS[1].name, "fog_theater");
assert_eq!(ROOMS[2].name, "occlusion_corridor");
@@ -396,6 +464,9 @@ mod tests {
assert_eq!(ROOMS[8].name, "sprint_gauntlet");
assert_eq!(ROOMS[9].name, "eavesdrop_alcove");
assert_eq!(ROOMS[10].name, "confrontation_stage");
assert_eq!(ROOMS[11].name, "sound_lab");
assert_eq!(ROOMS[12].name, "decay_observatory");
assert_eq!(ROOMS[13].name, "shift_change");
}
#[test]
@@ -458,6 +529,10 @@ mod tests {
EAVESDROP_ALCOVE_STABLE_IDS,
CONFRONTATION_STAGE_STABLE_IDS,
SPRINT11_RESET_PLATE_STABLE_IDS,
SOUND_LAB_STABLE_IDS,
DECAY_OBSERVATORY_STABLE_IDS,
SHIFT_CHANGE_STABLE_IDS,
SPRINT13_RESET_PLATE_STABLE_IDS,
];
for (i, a) in ranges.iter().enumerate() {
for (j, b) in ranges.iter().enumerate() {
+143 -1
View File
@@ -16,7 +16,7 @@
//! - Entities spawned in canonical order → StableId assignment is deterministic
//! - Additive-only: existing rooms/entities never reordered
//!
//! StableId ranges (from gestalt-round3.md + Sprint 11):
//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13):
//! Player: 0
//! Hub signs: 1-4
//! Fog Theater: 5-8
@@ -31,6 +31,10 @@
//! Eavesdrop Alcove: 58-60
//! Confrontation Stage: 61-62
//! Reset plates (Sprint 11 rooms): 63-65
//! Sound Lab: 66-68
//! Decay Observatory: 69
//! Shift Change: 70-71
//! Reset plates (Sprint 13 rooms): 72-74
#[cfg(feature = "gauntlet")]
pub mod constants;
@@ -64,6 +68,8 @@ use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomal
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
#[cfg(feature = "gauntlet")]
use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown};
#[cfg(feature = "gauntlet")]
use crate::simulation::zone::ZoneMap;
#[cfg(feature = "gauntlet")]
use reset::{RoomResetTrigger, RoomSnapshots};
@@ -101,6 +107,10 @@ pub fn setup_gauntlet(app: &mut App) {
carve_room_interior(&mut walkability, 0, 2, 28, 20); // Sprint Gauntlet
carve_room_interior(&mut walkability, 74, 26, 24, 16); // Eavesdrop Alcove
carve_room_interior(&mut walkability, 84, 2, 32, 24); // Confrontation Stage
// Sprint 13 rooms
carve_room_interior(&mut walkability, 0, 104, 34, 20); // Sound Lab
carve_room_interior(&mut walkability, 0, 24, 24, 14); // Decay Observatory
carve_room_interior(&mut walkability, 64, 78, 16, 24); // Shift Change
// Carve corridors between hub and rooms
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
@@ -130,6 +140,22 @@ pub fn setup_gauntlet(app: &mut App) {
app.insert_resource(walkability);
// Zone map: assign zone IDs per room bounding box (D-077, D-073).
// Zone IDs are sequential per ROOMS order. Corridors remain unzoned (None).
let mut zone_map = ZoneMap::default();
for (i, room) in constants::ROOMS.iter().enumerate() {
let zone_id = i as u16;
zone_map.set_rect(
room.origin.x,
room.origin.y,
room.size.0,
room.size.1,
room.origin.z,
zone_id,
);
}
app.insert_resource(zone_map);
// Entity spawning in canonical StableId order.
// Player gets StableId 0, then entities by room in workshop order.
let mut registry = EntityRegistry::new(0);
@@ -256,6 +282,15 @@ pub fn setup_gauntlet(app: &mut App) {
// --- Confrontation Stage (StableId 61-62) ---
rooms::confrontation_stage::spawn_entities(app, &mut registry);
// --- Sound Lab (StableId 66-68) ---
rooms::sound_lab::spawn_entities(app, &mut registry);
// --- Decay Observatory (StableId 69) ---
rooms::decay_observatory::spawn_entities(app, &mut registry);
// --- Shift Change (StableId 70-71) ---
rooms::shift_change::spawn_entities(app, &mut registry);
// --- Sprint 11 reset plates (StableId 63-65) ---
let sprint11_reset_plates: &[(&str, TilePosition)] = &[
(
@@ -294,6 +329,44 @@ pub fn setup_gauntlet(app: &mut App) {
.insert(StableEntityId(sid));
}
// --- Sprint 13 reset plates (StableId 72-74) ---
let sprint13_reset_plates: &[(&str, TilePosition)] = &[
(
"sound_lab",
constants::SOUND_LAB
.reset_plate
.expect("sound_lab should have a reset_plate"),
),
(
"decay_observatory",
constants::DECAY_OBSERVATORY
.reset_plate
.expect("decay_observatory should have a reset_plate"),
),
(
"shift_change",
constants::SHIFT_CHANGE
.reset_plate
.expect("shift_change should have a reset_plate"),
),
];
for &(room_name, pos) in sprint13_reset_plates {
let entity = app
.world_mut()
.spawn((
Interactable,
RoomResetTrigger {
room_name: room_name.to_string(),
},
pos,
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
// --- Populate RoomSnapshots for reset mechanism (#490) ---
let mut snapshots = RoomSnapshots::default();
@@ -390,6 +463,35 @@ pub fn setup_gauntlet(app: &mut App) {
}
}
// Sound Lab entities (StableId 66-68): NPCs only, no floor items
for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("sound_lab", entity, *pos, false);
}
}
}
// Decay Observatory entities (StableId 69): NPC only, no floor items
for id in
constants::DECAY_OBSERVATORY_STABLE_IDS.0..=constants::DECAY_OBSERVATORY_STABLE_IDS.1
{
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("decay_observatory", entity, *pos, false);
}
}
}
// Shift Change entities (StableId 70-71): NPCs only, no floor items
for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("shift_change", entity, *pos, false);
}
}
}
app.insert_resource(snapshots);
app.insert_resource(registry);
}
@@ -624,5 +726,45 @@ mod tests {
id
);
}
// Sound Lab at 66-68
for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sound Lab at StableId {}",
id
);
}
// Decay Observatory at 69
for id in constants::DECAY_OBSERVATORY_STABLE_IDS.0
..=constants::DECAY_OBSERVATORY_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Decay Observatory at StableId {}",
id
);
}
// Shift Change at 70-71
for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 {
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Shift Change at StableId {}",
id
);
}
// Sprint 13 reset plates at 72-74
for id in constants::SPRINT13_RESET_PLATE_STABLE_IDS.0
..=constants::SPRINT13_RESET_PLATE_STABLE_IDS.1
{
assert!(
registry.to_entity(&StableId(id)).is_some(),
"Sprint 13 reset plate at StableId {}",
id
);
}
}
}
@@ -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)"
);
}
}