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
+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
);
}
}
}