diff --git a/server/src/test_world/constants.rs b/server/src/test_world/constants.rs index 6ee3e8371..1459779bf 100644 --- a/server/src/test_world/constants.rs +++ b/server/src/test_world/constants.rs @@ -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() { diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 55eac4c93..937b65fde 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -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::(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::(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::(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 + ); + } } } diff --git a/server/src/test_world/rooms/decay_observatory.rs b/server/src/test_world/rooms/decay_observatory.rs new file mode 100644 index 000000000..7daef4412 --- /dev/null +++ b/server/src/test_world/rooms/decay_observatory.rs @@ -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" + ); + } +} diff --git a/server/src/test_world/rooms/mod.rs b/server/src/test_world/rooms/mod.rs index 5a69fcab0..14889c1cc 100644 --- a/server/src/test_world/rooms/mod.rs +++ b/server/src/test_world/rooms/mod.rs @@ -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; diff --git a/server/src/test_world/rooms/shift_change.rs b/server/src/test_world/rooms/shift_change.rs new file mode 100644 index 000000000..16e91a034 --- /dev/null +++ b/server/src/test_world/rooms/shift_change.rs @@ -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::(); + + // 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::().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::(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::(npc_afternoon).is_none(), + "npc_shift_afternoon already at Afternoon location — must NOT receive PathRequest" + ); + } +} diff --git a/server/src/test_world/rooms/sound_lab.rs b/server/src/test_world/rooms/sound_lab.rs new file mode 100644 index 000000000..9d2f6e056 --- /dev/null +++ b/server/src/test_world/rooms/sound_lab.rs @@ -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)" + ); + } +}