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:
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user