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() {