From c2de509c01181275b64b2d3da7c5b213ccaab24d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:05:46 +0100 Subject: [PATCH 1/3] feat(simulation): add Sound Lab, Decay Observatory, Shift Change gauntlet rooms (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/test_world/constants.rs | 79 ++++++- server/src/test_world/mod.rs | 144 +++++++++++- .../src/test_world/rooms/decay_observatory.rs | 145 ++++++++++++ server/src/test_world/rooms/mod.rs | 3 + server/src/test_world/rooms/shift_change.rs | 206 ++++++++++++++++++ server/src/test_world/rooms/sound_lab.rs | 161 ++++++++++++++ 6 files changed, 735 insertions(+), 3 deletions(-) create mode 100644 server/src/test_world/rooms/decay_observatory.rs create mode 100644 server/src/test_world/rooms/shift_change.rs create mode 100644 server/src/test_world/rooms/sound_lab.rs 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)" + ); + } +} From 51abc3e3c1faea960ddc661f262d6004a5b9ba9e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:10:06 +0100 Subject: [PATCH 2/3] feat(simulation): #523 add zone_id to ObserverSnapshot (D-077, protocol v11) Server-tracked zone_id on VisibleTile for D-073 zone crossfade and D-059 deep fog temperature tint. ZoneMap resource backed by BTreeMap, observer enrichment in snapshot assembly. Backwards-compatible: v10 payloads deserialize with zone_id: None. Co-Authored-By: Claude Opus 4.6 --- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 286 -> 286 bytes .../snapshot_boundary_tick_127.msgpack | Bin 286 -> 286 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 290 -> 290 bytes .../snapshot_boundary_tick_2b32.msgpack | Bin 294 -> 294 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 288 -> 288 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 286 -> 286 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 691 -> 691 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 384 -> 384 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 387 -> 387 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 533 -> 551 bytes decisions/perception.md | 17 +++- server/Cargo.lock | 2 +- server/src/bridge/text_renderer.rs | 1 + server/src/bridge/types.rs | 12 ++- server/src/perception/observer/mod.rs | 17 +++- server/src/perception/query.rs | 1 + server/src/simulation/mod.rs | 1 + server/src/simulation/zone.rs | 83 ++++++++++++++++++ server/tests/gen_fixtures.rs | 3 + server/tests/serialization.rs | 82 ++++++++++++++++- 20 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 server/src/simulation/zone.rs diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 3b499939158c6ec9f928f44be8d417950cca09fb..77b0f56856350ba30c9a7b1c090f60833af29181 100644 GIT binary patch delta 19 acmbQoG>?g^e|cGIQE_H|9`{Br5k>$-_Xc7B delta 19 acmbQoG>?g^e|cGIQE_H|9@j=L5k>$-@&;i5 diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 56c001dcf5d20729c9b60d4f2be711b6c2e8224e..fdd4b9555fe53dbb0442a452d4216fc97e75e294 100644 GIT binary patch delta 19 acmbQoG>?g^e|cGIQE_H|9`{Br5k>$-_Xc7B delta 19 acmbQoG>?g^e|cGIQE_H|9@j=L5k>$-@&;i5 diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 6ae68786f089233a2719b365fbd67172e66f9d3b..63f1799d3983aa9edc6a15f3806ac8df2ce31f1c 100644 GIT binary patch delta 19 acmZ3)w1|nTe|cGIQE_H|9`{Br2}S@$j|Of4 delta 19 acmZ3)w1|nTe|cGIQE_H|9@j=L2}S@$iUw@} diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 0822dc9c3abad26fc1570e3af0ec4037aea295d2..eb7d57eb1cc8d5529c2dd182239fca171bfb0381 100644 GIT binary patch delta 19 acmZ3+w2X?g^e|cGIQE_H|9`{Br5k>$-_Xc7B delta 19 acmbQoG>?g^e|cGIQE_H|9@j=L5k>$-@&;i5 diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 673d30a77768530d7cc637cc893fff14d613f9fe..d22f7b288734bef3646a154290b674a971d9aea1 100644 GIT binary patch delta 19 acmdnYx|x-$e|cGIQE_H|9`{DBl}rFid$l}rFicLyf` diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 0d336a7046bb3253e46662498686c2eee1d7af98..3d5bc895f9bc80db9785ff3964da3b64e118a08c 100644 GIT binary patch delta 19 acmZo*ZeZr>UtX44RGgWg$GwrOj1d4rr3Q)s delta 19 acmZo*ZeZr>UtX44RGgWg$F-5Gj1d4rpazKm diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index d22a725327f22d8f79751972039fc47a8be13e9a..8443ecb77fd83529cf22f1a08f74775a6bb4c7f5 100644 GIT binary patch delta 19 acmZo>Zf54{UtX44RGgWg$GwrOk`Vwy7Y366 delta 19 acmZo>Zf54{UtX44RGgWg$F-5Gk`Vwy5(bh0 diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 2aa4f71c82617df3935fe6857b7d5e8135970a0f..97ecb1922f75ca26dd16c316f4ae44b4c7c9a43a 100644 GIT binary patch delta 54 zcmbQrvYdshe|cGIQE_H|9`{BrW=6)g$$X4DT+6HS^HSq8Q-EA+Mtx2=cXAfvY5?o= B5()qS delta 35 pcmZ3^GL?m^e|cGIQE_H|9@j=LW=6)=$$X4DK+=U#e{v1uY5>l93h)2` diff --git a/decisions/perception.md b/decisions/perception.md index c4675ee4a..c352b9d12 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -356,6 +356,21 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Amends:** [D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits) (adds pixel value for max-width) - **Raised by:** Stig (OQ-29), revised per Tyre architecture review +### D-077: Zone temperature memory — server-tracked zone_id (OQ-09 resolution) +- **Date:** 2026-02-19 +- **Decision:** Zone temperature memory is **server-tracked** via `zone_id: Option` on `VisibleTile` in `ObserverSnapshot`. The server assigns a zone ID to each tile based on the `ZoneMap` resource (spatial zone assignment). The client maps `zone_id` to temperature tint from a local lookup table for deep fog rendering ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven) layer 3: ~10% zone temperature tint — bar=warm dark, hub=cool dark, corridor=neutral dark). +- **Resolves:** OQ-09 +- **Rationale:** D-073 already mandates `zone_id` per tile in `ObserverSnapshot` for audio zone crossfade. With `zone_id` already on the wire for crossfade, zone temperature memory is essentially free — no additional protocol field needed, no additional server computation beyond the zone lookup. Client-only tracking was rejected because: (1) the server is the authoritative source of zone geometry, (2) client heuristics (remembering last-visited zone) would diverge from server truth at zone boundaries, and (3) the data is already crossing the wire for D-073. +- **Implementation:** + - `VisibleTile.zone_id: Option` — `None` for tiles outside any defined zone (corridors, transition spaces). Uses `#[serde(default, skip_serializing_if)]` for backwards compatibility with v10 clients. + - `ZoneMap` resource (`simulation/zone.rs`): `BTreeMap<(i32, i32, i32), u16>` mapping tile coordinates to zone IDs. BTreeMap per [D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline) principle 4 (deterministic iteration). Populated by map builders at setup time. + - Zone enrichment runs in `compute_observer_snapshot` — the observer pipeline's final assembly stage. `PerceptionQuery` trait remains zone-unaware (zone assignment is map data, not perception geometry). + - Protocol version bumped to 11. +- **Client contract:** Client maintains a `zone_id → { name, temperature_tint, ambient_layer }` lookup table. Deep fog shader (layer 3) reads `zone_id` from the last-seen `VisibleTile` data to apply the ~10% temperature tint. AudioManager reads `zone_id` to trigger crossfade between ambient layers ([D-073](architecture.md#d-073-zone-crossfade-approach--hard-boundary-soft-audio-transition)). +- **Cross-reference:** Fog layers ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven)), zone crossfade ([D-073](architecture.md#d-073-zone-crossfade-approach--hard-boundary-soft-audio-transition)), ObserverSnapshot ([D-020](architecture.md#d-020-engine-and-architecture-selection--godot-client--rust-simulation-via-subprocessipc)), deterministic simulation ([D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline)) +- **Raised by:** Tyre (architecture review, #523) +- **Dissent:** None + --- -*30 decisions. Last updated: 2026-02-19 (D-076: OQ-29 resolved — dialogue max-width 640px)* +*31 decisions. Last updated: 2026-02-19 (D-077: OQ-09 resolved — zone temperature memory server-tracked)* diff --git a/server/Cargo.lock b/server/Cargo.lock index 21c88b2a2..ea1dcf2fe 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.11" +version = "0.1.12" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index baf887368..8abc99478 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -268,6 +268,7 @@ mod tests { z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, + zone_id: None, }], nearby_interactions: vec![NearbyInteraction { entity_id: 100, diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 93fd77650..5396fe634 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 10; +pub const PROTOCOL_VERSION: u8 = 11; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -30,10 +30,11 @@ pub const PROTOCOL_VERSION: u8 = 10; /// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities). /// v10 adds: sound_events (#124, D-038 server sound event pipeline), /// rng_seed (#527, deterministic replay — completes WRONG button loop). +/// v11 adds: zone_id on VisibleTile (#523, D-077 OQ-09 resolution + D-073 crossfade). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 10. + /// Protocol version for forward compatibility. Current: 11. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -209,6 +210,13 @@ pub struct VisibleTile { /// Tile type for client rendering (floor, wall, door, object) #[serde(default)] pub tile_kind: TileKind, + /// Zone identifier for this tile (D-077 OQ-09, D-073 crossfade). + /// Server-authoritative zone assignment. Client maps zone_id to: + /// - Audio crossfade target (D-073) + /// - Deep fog temperature tint (D-059 layer 3) + /// None for tiles outside any defined zone (corridors, transition spaces). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zone_id: Option, } /// Tile type for rendering. Derived from WalkabilityMap on the server side. diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index aadd251df..089e688e7 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -26,6 +26,7 @@ use crate::simulation::rng::SimRng; use crate::simulation::sound::SoundEventQueue; use crate::simulation::stance::Stance; use crate::simulation::time::SimulationTime; +use crate::simulation::zone::ZoneMap; /// Compute visibility geometry using the active perception mode. /// Stage 1 of the observer pipeline: FOV + vision cone → VisibilityGeometry. @@ -61,6 +62,7 @@ pub fn compute_observer_snapshot( time: Res, geometry: Res, registry: Res, + zone_map: Option>, sound_queue: Option>, mut observer_query: Query< ( @@ -221,6 +223,19 @@ pub fn compute_observer_snapshot( // Sort entities by entity_id for deterministic snapshot ordering (#457) entities.sort_by_key(|e| e.entity_id); + // Enrich tiles with zone_id from ZoneMap (D-077, D-073) + let visible_tiles = match zone_map.as_deref() { + Some(zm) => geometry + .visible_tiles + .iter() + .map(|t| VisibleTile { + zone_id: zm.zone_at(t.x, t.y, t.z), + ..t.clone() + }) + .collect(), + None => geometry.visible_tiles.clone(), + }; + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -229,7 +244,7 @@ pub fn compute_observer_snapshot( player_stance: stance_opt.map(|s| s.0).unwrap_or_default(), player_inventory, entities, - visible_tiles: geometry.visible_tiles.clone(), + visible_tiles, nearby_interactions, current_monologue, pending_recognitions, diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs index af6057345..b38500e98 100644 --- a/server/src/perception/query.rs +++ b/server/src/perception/query.rs @@ -83,6 +83,7 @@ impl PerceptionQuery for NaturalVision { z, visibility: sector, tile_kind, + zone_id: None, } }) .collect(); diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index b85bc6a63..af7e66716 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -19,6 +19,7 @@ pub mod sound; pub mod stance; pub mod tier; pub mod time; +pub mod zone; /// Core simulation plugin /// Manages simulation time, RNG, input processing, and tier transitions diff --git a/server/src/simulation/zone.rs b/server/src/simulation/zone.rs new file mode 100644 index 000000000..baccf096a --- /dev/null +++ b/server/src/simulation/zone.rs @@ -0,0 +1,83 @@ +//! Zone map — spatial zone assignment for tiles (D-077, D-073). +//! +//! Maps tile coordinates to zone identifiers. Used by: +//! - Observer snapshot: enriches VisibleTile with zone_id +//! - Client AudioManager: zone crossfade triggers (D-073) +//! - Client fog shader: deep fog temperature tint (D-059 layer 3) +//! +//! BTreeMap per D-010 principle 4 (deterministic iteration). + +use std::collections::BTreeMap; + +use bevy_ecs::prelude::*; + +/// Server-authoritative zone assignment for tiles. +/// +/// Each tile position maps to a zone ID. Tiles outside any defined zone +/// (corridors, transition spaces) have no entry and return None. +/// +/// Zone IDs are opaque u16 values — the client maintains its own +/// `zone_id → zone_name / temperature_tint / ambient_layer` mapping. +#[derive(Resource, Debug, Default)] +pub struct ZoneMap { + zones: BTreeMap<(i32, i32, i32), u16>, +} + +impl ZoneMap { + /// Look up the zone for a tile position. + pub fn zone_at(&self, x: i32, y: i32, z: i32) -> Option { + self.zones.get(&(x, y, z)).copied() + } + + /// Assign a zone to a rectangular region of tiles. + /// Used by map builders to define zone boundaries. + pub fn set_rect(&mut self, ox: i32, oy: i32, w: i32, h: i32, z: i32, zone_id: u16) { + for y in oy..(oy + h) { + for x in ox..(ox + w) { + self.zones.insert((x, y, z), zone_id); + } + } + } + + /// Assign a zone to a single tile. + pub fn set(&mut self, x: i32, y: i32, z: i32, zone_id: u16) { + self.zones.insert((x, y, z), zone_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zone_at_returns_none_for_unset_tile() { + let map = ZoneMap::default(); + assert_eq!(map.zone_at(10, 20, 0), None); + } + + #[test] + fn set_rect_populates_zone() { + let mut map = ZoneMap::default(); + map.set_rect(5, 10, 3, 2, 0, 42); + + // Inside rect + assert_eq!(map.zone_at(5, 10, 0), Some(42)); + assert_eq!(map.zone_at(7, 11, 0), Some(42)); + + // Outside rect + assert_eq!(map.zone_at(4, 10, 0), None); + assert_eq!(map.zone_at(8, 10, 0), None); + assert_eq!(map.zone_at(5, 12, 0), None); + + // Wrong z-level + assert_eq!(map.zone_at(5, 10, 1), None); + } + + #[test] + fn set_single_tile() { + let mut map = ZoneMap::default(); + map.set(3, 7, 0, 99); + assert_eq!(map.zone_at(3, 7, 0), Some(99)); + assert_eq!(map.zone_at(3, 8, 0), None); + } +} diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index f82126f3c..fb0d6f0a0 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -188,6 +188,7 @@ fn generate_msgpack_fixtures() { z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, + zone_id: Some(1), }, VisibleTile { x: 11, @@ -195,6 +196,7 @@ fn generate_msgpack_fixtures() { z: 0, visibility: VisibilitySector::Peripheral, tile_kind: TileKind::Floor, + zone_id: Some(1), }, VisibleTile { x: 10, @@ -202,6 +204,7 @@ fn generate_msgpack_fixtures() { z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, + zone_id: None, }, ], nearby_interactions: vec![], diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 5b9ecf367..ff9362f09 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -241,6 +241,7 @@ fn snapshot_v2_fields_roundtrip() { z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, + zone_id: None, }, VisibleTile { x: 6, @@ -248,6 +249,7 @@ fn snapshot_v2_fields_roundtrip() { z: 0, visibility: VisibilitySector::Peripheral, tile_kind: TileKind::Wall, + zone_id: None, }, ], nearby_interactions: vec![], @@ -312,7 +314,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 10, + PROTOCOL_VERSION, 11, "bump this assertion when protocol version changes" ); } @@ -862,6 +864,7 @@ fn boundary_value_in_tile_position() { z: 0, visibility: VisibilitySector::Forward, tile_kind: TileKind::Floor, + zone_id: None, }]; let bytes = rmp_serde::to_vec_named(&snapshot) .unwrap_or_else(|e| panic!("encode tile x/y={} failed: {}", tile_val, e)); @@ -1264,6 +1267,83 @@ fn v9_payload_deserializes_into_v10_struct() { ); } +/// v10 payload (without zone_id on VisibleTile) deserializes into the v11 struct +/// via #[serde(default)]. Guards backwards compat during migration (#523, D-077). +#[test] +fn v10_payload_deserializes_into_v11_struct() { + // V10 VisibleTile: no zone_id field + #[derive(serde::Serialize)] + struct VisibleTileV10 { + x: i32, + y: i32, + z: i32, + visibility: VisibilitySector, + tile_kind: TileKind, + } + + #[derive(serde::Serialize)] + struct ObserverSnapshotV10 { + version: u8, + tick: u64, + game_time: GameTime, + player_facing: FacingDirection, + player_stance: MovementStance, + player_inventory: Vec, + entities: Vec, + visible_tiles: Vec, + nearby_interactions: Vec, + current_monologue: Option, + pending_recognitions: Vec, + dialogue_response: Option, + blocked_entities: Vec, + scan_events: Vec, + sound_events: Vec, + rng_seed: Option, + } + + let v10 = ObserverSnapshotV10 { + version: 10, + tick: 300, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::North, + player_stance: MovementStance::Walk, + player_inventory: vec![], + entities: vec![], + visible_tiles: vec![VisibleTileV10 { + x: 5, + y: 10, + z: 0, + visibility: VisibilitySector::Forward, + tile_kind: TileKind::Floor, + }], + nearby_interactions: vec![], + current_monologue: None, + pending_recognitions: vec![], + dialogue_response: None, + blocked_entities: vec![], + scan_events: vec![], + sound_events: vec![], + rng_seed: Some(42), + }; + + let bytes = rmp_serde::to_vec_named(&v10).expect("serialize v10"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes) + .expect("v10 payload should deserialize into v11 struct via serde(default)"); + + assert_eq!(decoded.version, 10, "version field preserved from v10"); + assert_eq!(decoded.tick, 300); + assert_eq!(decoded.visible_tiles.len(), 1); + assert_eq!( + decoded.visible_tiles[0].zone_id, None, + "missing zone_id should default to None" + ); +} + /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test] From dd1dd0feec8823a45d8d288d60bba63b25fd853a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:22:48 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(simulation):=20address=20PR=20#46=20rev?= =?UTF-8?q?iew=20=E2=80=94=20zone=20tests,=20doc=20accuracy,=20TBD=20comme?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 3 zone enrichment tests to observer pipeline (tile inside zone, tile outside zone, absent ZoneMap resource). Fix misleading Decay Observatory doc comment that claimed Direct start when test starts at KnowsDetails. Add production population TBD note on ZoneMap. Co-Authored-By: Claude Opus 4.6 --- server/src/perception/observer/tests.rs | 113 ++++++++++++++++++ server/src/simulation/zone.rs | 3 + .../src/test_world/rooms/decay_observatory.rs | 7 +- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index f2b1d3024..d564eac2f 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -2466,3 +2466,116 @@ fn blocked_entities_sorted_ascending() { assert!(snapshot.blocked_entities.contains(&npc_b_sid.0)); assert!(snapshot.blocked_entities.contains(&npc_c_sid.0)); } + +// ----------------------------------------------------------------------- +// Zone enrichment tests (#523, D-077) +// ----------------------------------------------------------------------- + +#[test] +fn zone_map_enriches_visible_tiles_with_zone_id() { + // Tile inside a zone should get zone_id = Some(zone_id) + use crate::simulation::zone::ZoneMap; + + let mut world = setup_world(32, 32); + let mut zone_map = ZoneMap::default(); + // Zone 42 covers (14..18, 14..18) — includes the observer's tile at (16,16) + zone_map.set_rect(14, 14, 4, 4, 0, 42); + world.insert_resource(zone_map); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + // Observer's tile (16, 16) is inside zone 42 + let observer_tile = snapshot + .visible_tiles + .iter() + .find(|t| t.x == 16 && t.y == 16 && t.z == 0) + .expect("observer tile should be visible"); + assert_eq!( + observer_tile.zone_id, + Some(42), + "tile inside zone should have zone_id" + ); +} + +#[test] +fn zone_map_tiles_outside_zone_get_none() { + // Tile outside any zone should get zone_id = None + use crate::simulation::zone::ZoneMap; + + let mut world = setup_world(32, 32); + let mut zone_map = ZoneMap::default(); + // Zone only covers (0..2, 0..2) — far from observer at (16,16) + zone_map.set_rect(0, 0, 2, 2, 0, 7); + world.insert_resource(zone_map); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + // Observer's tile (16, 16) is outside any zone + let observer_tile = snapshot + .visible_tiles + .iter() + .find(|t| t.x == 16 && t.y == 16 && t.z == 0) + .expect("observer tile should be visible"); + assert_eq!( + observer_tile.zone_id, None, + "tile outside any zone should have zone_id = None" + ); +} + +#[test] +fn no_zone_map_resource_tiles_have_no_zone_id() { + // When ZoneMap resource is absent, all tiles should have zone_id = None + let mut world = setup_world(32, 32); + // Do NOT insert ZoneMap resource + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + assert!( + !snapshot.visible_tiles.is_empty(), + "should have visible tiles" + ); + // All tiles should have zone_id = None when no ZoneMap exists + for tile in &snapshot.visible_tiles { + assert_eq!( + tile.zone_id, None, + "tile ({},{}) should have zone_id = None without ZoneMap resource", + tile.x, tile.y + ); + } +} diff --git a/server/src/simulation/zone.rs b/server/src/simulation/zone.rs index baccf096a..ebefd79ef 100644 --- a/server/src/simulation/zone.rs +++ b/server/src/simulation/zone.rs @@ -18,6 +18,9 @@ use bevy_ecs::prelude::*; /// /// Zone IDs are opaque u16 values — the client maintains its own /// `zone_id → zone_name / temperature_tint / ambient_layer` mapping. +/// +/// Production population path is TBD — currently populated only by +/// Gauntlet room builders via `set_rect` / `set`. #[derive(Resource, Debug, Default)] pub struct ZoneMap { zones: BTreeMap<(i32, i32, i32), u16>, diff --git a/server/src/test_world/rooms/decay_observatory.rs b/server/src/test_world/rooms/decay_observatory.rs index 7daef4412..152a88e50 100644 --- a/server/src/test_world/rooms/decay_observatory.rs +++ b/server/src/test_world/rooms/decay_observatory.rs @@ -1,8 +1,9 @@ //! 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). +//! Tests D-041 (knowledge graph decay). Observer sees an NPC (Direct), then +//! LOS is broken (drops to KnowsDetails). Tick advances verify confidence +//! degrades: KnowsDetails → KnowsOf → Suspects (floor). Separate test +//! confirms Stale state triggers when age exceeds stale_after threshold. //! //! Decay runs once per game-minute (every 10 ticks, D-031). Confidence floor //! is Suspects — decay never removes an entity from the knowledge graph.