diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 3b4999391..77b0f5685 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 56c001dcf..fdd4b9555 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 6ae68786f..63f1799d3 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 0822dc9c3..eb7d57eb1 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 69657bc29..aaeee140b 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 3b4999391..77b0f5685 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 673d30a77..d22f7b288 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 0d336a704..3d5bc895f 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index d22a72532..8443ecb77 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 2aa4f71c8..97ecb1922 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ 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/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/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..ebefd79ef --- /dev/null +++ b/server/src/simulation/zone.rs @@ -0,0 +1,86 @@ +//! 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. +/// +/// 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>, +} + +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/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..152a88e50 --- /dev/null +++ b/server/src/test_world/rooms/decay_observatory.rs @@ -0,0 +1,146 @@ +//! Decay Observatory — Room 13 (24x14) +//! +//! 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. +//! +//! 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)" + ); + } +} 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]