From a394ff6a5f5a63ae187d06706efbab25267f60e9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 17:10:17 +0100 Subject: [PATCH 01/14] feat(audio): NPC conversation murmur asset (SFX-003) Produces sfx_npc_murmur.ogg via Stable Audio Open for D-072 universal event-driven NPC proximity murmur. Organic sonic family (D-074): warm mid-freq 200-800Hz vocal texture, human voices in adjacent room with direction and cadence distinguishable, words not. World SFX bus. Generated 3 candidates (5s/CFG8, 6s/CFG7, 4s/CFG9), selected candidate 1 for richest texture and clearest spatial framing. Unblocks client #533. Co-Authored-By: Claude Sonnet 4.6 --- client/assets/audio/sfx_npc_murmur.ogg | Bin 0 -> 107070 bytes docs/assets/audio/sfx.md | 13 ++++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 client/assets/audio/sfx_npc_murmur.ogg diff --git a/client/assets/audio/sfx_npc_murmur.ogg b/client/assets/audio/sfx_npc_murmur.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b401d73508d9532b236f3e30d796d37bddd052fa GIT binary patch literal 107070 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 Date: Thu, 19 Feb 2026 18:05:46 +0100 Subject: [PATCH 02/14] 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 03/14] 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 96275c5b9031513756b98a28d15d224023d14f3e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:10:07 +0100 Subject: [PATCH 04/14] fix(skills): add agent rules for git and db script usage in sprint-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawned agents are told not to run git commands (team lead handles those) and to call db/connectors scripts with bare commands only — no python3 prefix or command chaining — so they match the permission whitelist. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/sprint-start/SKILL.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.claude/skills/sprint-start/SKILL.md b/.claude/skills/sprint-start/SKILL.md index 957ea3886..e8b390ef4 100644 --- a/.claude/skills/sprint-start/SKILL.md +++ b/.claude/skills/sprint-start/SKILL.md @@ -288,6 +288,17 @@ Task( prompt: "You are on the {team} team for Sprint {N}. Branch: `{team}` + RULES: + - GIT: Do NOT run any git commands (commit, push, pull, merge, + checkout, branch, stash, tag, etc.). All git operations are + handled by the team lead. + - DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use + the exact command with no wrappers or chaining. Examples: + db/connectors/ticket show 528 + db/connectors/ticket list --sprint {N} + Do NOT prepend python3, do NOT chain with && or ;, do NOT + add cleanup commands. Just the bare command. + 1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md 2. Read the decision files referenced in the briefing. 3. Check TaskList for available work. From 0e6cc4eef897b290df69983373e1848267480fc8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:19:25 +0100 Subject: [PATCH 05/14] =?UTF-8?q?feat(audio):=20Sprint=2013=20=E2=80=94=20?= =?UTF-8?q?AudioManager,=20zone=20crossfade,=20dip=20profiles,=20murmur=20?= =?UTF-8?q?wiring=20(#528,=20#529,=20#530,=20#533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AudioManager: volume slider persistence (ConfigFile), settings UI with 5 teal-bordered sliders, default_bus_layout.tres for editor. Zone crossfade: defensive zone_id read from snapshot tiles, 1.5-2s ambient tween, auto-activates when server ships OQ-09. Dip profiles: dialogue dip in show/hide_dialogue, ListeningFocus 30-tick gate via stationary_ticks in game_state.gd. NPC murmur: client plumbing for event-driven World SFX playback, no-ops until audio asset arrives. Co-Authored-By: Claude Opus 4.6 --- client/default_bus_layout.tres | 43 ++++++ client/project.godot | 4 + client/scenes/main.tscn | 6 +- client/scripts/autoloads/audio_manager.gd | 104 ++++++++++++-- client/scripts/autoloads/game_state.gd | 14 ++ client/scripts/main.gd | 51 +++++++ client/ui/dialogue_box.gd | 8 ++ client/ui/settings_dialog.gd | 159 ++++++++++++++++++++++ client/ui/settings_dialog.tscn | 14 ++ server/Cargo.lock | 2 +- 10 files changed, 395 insertions(+), 10 deletions(-) create mode 100644 client/default_bus_layout.tres create mode 100644 client/ui/settings_dialog.gd create mode 100644 client/ui/settings_dialog.tscn diff --git a/client/default_bus_layout.tres b/client/default_bus_layout.tres new file mode 100644 index 000000000..ebe4e8c59 --- /dev/null +++ b/client/default_bus_layout.tres @@ -0,0 +1,43 @@ +[gd_resource type="AudioBusLayout" format=3] + +; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds. +; All buses route to Master. Volumes managed at runtime by AudioManager autoload. +; AudioManager._setup_buses() creates any missing buses on startup (no-op if present). + +[resource] +bus/0/name = "Master" +bus/0/solo = false +bus/0/mute = false +bus/0/bypass_fx = false +bus/0/volume_db = 0.0 +bus/0/send = &"" +bus/1/name = "Music" +bus/1/solo = false +bus/1/mute = false +bus/1/bypass_fx = false +bus/1/volume_db = 0.0 +bus/1/send = &"Master" +bus/2/name = "Ambient" +bus/2/solo = false +bus/2/mute = false +bus/2/bypass_fx = false +bus/2/volume_db = 0.0 +bus/2/send = &"Master" +bus/3/name = "WorldSFX" +bus/3/solo = false +bus/3/mute = false +bus/3/bypass_fx = false +bus/3/volume_db = 0.0 +bus/3/send = &"Master" +bus/4/name = "PlayerActions" +bus/4/solo = false +bus/4/mute = false +bus/4/bypass_fx = false +bus/4/volume_db = 0.0 +bus/4/send = &"Master" +bus/5/name = "UISounds" +bus/5/solo = false +bus/5/mute = false +bus/5/bypass_fx = false +bus/5/volume_db = 0.0 +bus/5/send = &"Master" diff --git a/client/project.godot b/client/project.godot index af0fb088e..d87dd633c 100644 --- a/client/project.godot +++ b/client/project.godot @@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd" FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" +[audio] + +buses/default_bus_layout="res://default_bus_layout.tres" + [gui] theme/custom="res://assets/theme/game_theme.tres" diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index fa52fcf7d..c77b4e6b7 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=21 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=22 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -20,6 +20,7 @@ [ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"] [ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] [ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] +[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -162,3 +163,6 @@ layer = 30 ; #495: WRONG button (F12) — bug report capture dialog [node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")] + +; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle +[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")] diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index 3fc0f64be..ba35c5b20 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -41,6 +41,18 @@ const DIP_SPECS := { # Low-pass filter default cutoff — effectively bypassed at this value. const FILTER_CUTOFF_DEFAULT := 20500.0 +# --- D-073: Zone crossfade constants --- +const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range + +# Maps server zone_id strings to ambient asset keys (filenames in res://audio/). +# Hub and Workplace share the same ambient layer — same location type. +const ZONE_ASSETS: Dictionary = { + "hub": "amb_hub_layer", + "workplace": "amb_hub_layer", + "bar": "amb_bar_layer", + "corridor": "amb_corridor_layer", +} + # Asset registry: filename stem (e.g. "amb_station_base") → AudioStream var _registry: Dictionary = {} @@ -57,12 +69,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null # Ambient loop players keyed by asset_key (D-073 zone crossfade) var _ambient_players: Dictionary = {} +# D-073: Zone crossfade state +var _current_zone_id: String = "" +var _zone_tweens: Array = [] + signal dip_changed(profile: String) +const PREFS_PATH := "user://audio_prefs.cfg" + func _ready() -> void: _setup_buses() _scan_registry() + _load_prefs() # --- Bus setup --- @@ -173,9 +192,8 @@ func stop_all_loops() -> void: # --- Audio asset registry: event type → asset key (D-018, #125) --- # Maps server-sent sound event_type strings to audio asset keys. # Keys match filename stems in res://audio/ (scanned by _scan_registry). -# Audio assets per D-038: footstep variants (walk / run). -# No asset for Voice events in v0.1 — play_sound_event no-ops gracefully -# (D-038: "renders as audio if asset exists, or visual indicator + monologue if not"). +# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532). +# Missing assets no-op gracefully (D-038 fallback pattern). const SOUND_EVENT_ASSETS: Dictionary = { "Footstep": "sfx_footstep_metal_walk", "FootstepWalk": "sfx_footstep_metal_walk", @@ -183,6 +201,10 @@ const SOUND_EVENT_ASSETS: Dictionary = { "FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands "FootstepSprint": "sfx_footstep_metal_run", "FootstepRun": "sfx_footstep_metal_run", + # D-072 (#533): NPC conversation murmur — single universal asset, World SFX bus. + # Zone ambient provides natural conspicuousness (bar buries it, corridor exposes it). + "Voice": "sfx_npc_murmur", + "VoiceConversation": "sfx_npc_murmur", } @@ -304,19 +326,85 @@ func set_volume(bus: String, volume_db: float) -> void: var buses: Dictionary = spec.get("buses", {}) var offset_db: float = buses.get(bus, 0.0) AudioServer.set_bus_volume_db(idx, volume_db + offset_db) + _save_prefs() func get_volume(bus: String) -> float: return _bus_volumes.get(bus, 0.0) -# --- Zone crossfade (D-073 stub) --- +# --- Volume persistence (user://audio_prefs.cfg) --- + +func _load_prefs() -> void: + var cfg := ConfigFile.new() + if cfg.load(PREFS_PATH) != OK: + return + for bus_name in BUSES: + if cfg.has_section_key("audio", bus_name): + var db: float = cfg.get_value("audio", bus_name, 0.0) + # Apply directly: bypass _save_prefs() on initial load. + _bus_volumes[bus_name] = db + var idx := AudioServer.get_bus_index(bus_name) + if idx >= 0: + AudioServer.set_bus_volume_db(idx, db) + + +func _save_prefs() -> void: + var cfg := ConfigFile.new() + for bus_name in BUSES: + cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0)) + var err := cfg.save(PREFS_PATH) + if err != OK: + push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err]) + + +# --- Zone crossfade (D-073) --- ## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot. -## Full crossfade implementation deferred to Sprint 9+ (D-073). -## Stub exists so server integration can call it without conditional checks. -func set_zone(_zone_id: String) -> void: - pass +## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers. +## Interruptible — mid-crossfade zone change tweens from current position. +## No-op if assets absent (D-038) or same zone. +func set_zone(zone_id: String) -> void: + if zone_id == _current_zone_id: + return + var new_asset: String = ZONE_ASSETS.get(zone_id, "") + var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "") + _current_zone_id = zone_id + _kill_zone_tweens() + + # Fade out old ambient layer (if different asset from incoming zone) + if not old_asset.is_empty() and old_asset != new_asset: + if _ambient_players.has(old_asset): + var old_player: AudioStreamPlayer = _ambient_players[old_asset] + if is_instance_valid(old_player): + var tween := create_tween() + tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION) + tween.tween_callback(stop_loop.bind(old_asset)) + _zone_tweens.append(tween) + + # Fade in new ambient layer + if not new_asset.is_empty(): + if _ambient_players.has(new_asset): + # Already playing (interrupted reverse crossfade) — tween from current volume + var existing: AudioStreamPlayer = _ambient_players[new_asset] + if is_instance_valid(existing): + var tween := create_tween() + tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION) + _zone_tweens.append(tween) + elif has_asset(new_asset): + var new_player := play_loop(new_asset, BUS_AMBIENT) + if new_player: + new_player.volume_db = -80.0 + var tween := create_tween() + tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION) + _zone_tweens.append(tween) + + +func _kill_zone_tweens() -> void: + for tween in _zone_tweens: + if tween != null and tween.is_valid(): + tween.kill() + _zone_tweens.clear() # --- Internal helpers --- diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 4f3045158..2b912d2e5 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -62,6 +62,12 @@ var medium_sound_events: Array = [] # Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd. var close_sound_events: Array = [] +# D-071 (#530): Consecutive ticks without player position change. +# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement. +# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip). +var stationary_ticks: int = 0 +var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -83,6 +89,14 @@ func apply_snapshot(snapshot: Dictionary) -> void: push_warning("GameState: no Player entity found in %d entities" % [ visible_entities.size()]) + # D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost. + # Compares current player_position against previous snapshot's position. + if player_position == _prev_player_position: + stationary_ticks += 1 + else: + stationary_ticks = 0 + _prev_player_position = player_position + # Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles" if snapshot.has("tiles"): visible_tiles = snapshot.tiles diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 677c7ebf4..16b4eb2dd 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -15,6 +15,7 @@ extends Node2D @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button +@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _camera_anchored: bool = false @@ -24,6 +25,9 @@ var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have alrea var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival +var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades + +const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates func _ready() -> void: print("The Settled Reach — client initialized") @@ -128,6 +132,13 @@ func _process(_delta: float) -> void: # D-018 #125: Play close-range sound events via positional 2D audio _play_close_sound_events() + # D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change. + _update_zone() + + # D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX. + # Only activates when no dialogue/confrontation dip is active (D-070). + _update_listening_focus() + # Show monologue if server sent one this tick (#414) _consume_monologue() @@ -162,6 +173,14 @@ func _process(_delta: float) -> void: if bug_report_dialog and not bug_report_dialog.is_active(): bug_report_dialog.start_capture() continue + # #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog + if input.action == InputMapper.Action.OPEN_MENU: + if settings_dialog: + if settings_dialog.is_open(): + settings_dialog.close() + else: + settings_dialog.open() + continue if input.action == InputMapper.Action.INTERACT: # D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1) var target_id: int = -1 @@ -235,6 +254,38 @@ func _play_recognition_chimes() -> void: _known_recognition_ids.erase(eid) +# D-073 (#529): Zone ambient crossfade — read zone_id from player's current tile. +# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade). +# zone_id field is server-authoritative; missing field treated as empty (no zone). +func _update_zone() -> void: + var px := int(GameState.player_position.x) + var py := int(GameState.player_position.y) + var zone: String = "" + for tile in GameState.visible_tiles: + if not tile is Dictionary: + continue + if tile.get("x") == px and tile.get("y") == py: + zone = tile.get("zone_id", "") + break + if zone != _current_zone: + _current_zone = zone + AudioManager.set_zone(zone) + + +# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks. +# Uses AudioManager.get_active_dip() as single source of truth (no separate flag). +# Only activates when no other dip (dialogue/confrontation) is running. +# Only deactivates its own dip — never touches dialogue/confrontation. +# D-070: no UI indicator — the boost is "felt, not computed." +func _update_listening_focus() -> void: + var current_dip := AudioManager.get_active_dip() + var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS + if threshold_met and current_dip == "": + AudioManager.apply_dip("listening_focus") + elif not threshold_met and current_dip == "listening_focus": + AudioManager.clear_dip() + + # Consume-once per tick: show monologue text, then clear. # Tick guard prevents re-triggering when the same tick is polled multiple # times (client FPS > sim tick rate). diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 62c71b53b..e64363929 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -139,6 +139,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi _is_showing = true GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading + # D-069: Dialogue dip — reduce ambient noise to foreground conversation. + # Confrontation options override this with apply_dip("confrontation") in _start_confrontation_beat(). + AudioManager.apply_dip("dialogue") + # D-061: auto-pause in single-player when dialogue opens SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}) @@ -156,6 +160,10 @@ func hide_dialogue() -> void: _is_showing = false mouse_filter = Control.MOUSE_FILTER_IGNORE + # D-069: Clear dialogue/confrontation dip — restore bus volumes to player slider settings. + # Safe to call even if confrontation beat already cleared the dip (no-op when empty). + AudioManager.clear_dip() + # D-061: unpause when dialogue closes SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}) diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd new file mode 100644 index 000000000..588e9b4eb --- /dev/null +++ b/client/ui/settings_dialog.gd @@ -0,0 +1,159 @@ +extends Control + +## #528: Audio settings dialog — 5-bus volume sliders. +## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button. +## Volumes persist via AudioManager._save_prefs() on each slider change. + +const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90) +const BORDER_COLOR := Color("#4a9ebb") +const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1) +const TITLE_COLOR := Color("#4a9ebb") +const FONT_SIZE := 14 + +const BOX_WIDTH := 460 +const BOX_HEIGHT := 340 +const PADDING := 20 +const ROW_HEIGHT := 36 + +# Bus display labels → bus name strings (must match AudioManager BUS_* constants) +const BUS_ROWS: Array = [ + ["Music", "Music"], + ["Ambient", "Ambient"], + ["World SFX", "WorldSFX"], + ["Player Actions", "PlayerActions"], + ["UI Sounds", "UISounds"], +] + +var _active: bool = false +var _container: VBoxContainer = null + +signal closed + + +func _ready() -> void: + visible = false + mouse_filter = Control.MOUSE_FILTER_STOP + + +func open() -> void: + if _active: + return + _active = true + visible = true + _build_ui() + queue_redraw() + + +func close() -> void: + if not _active: + return + _active = false + visible = false + _destroy_ui() + queue_redraw() + closed.emit() + + +func is_open() -> bool: + return _active + + +func _build_ui() -> void: + var vp_size := get_viewport_rect().size + var box_pos := Vector2( + (vp_size.x - BOX_WIDTH) / 2.0, + (vp_size.y - BOX_HEIGHT) / 2.0 + ) + + _container = VBoxContainer.new() + _container.position = box_pos + Vector2(PADDING, PADDING + 28) + _container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0) + _container.add_theme_constant_override("separation", 4) + add_child(_container) + + for row_data in BUS_ROWS: + var label_text: String = row_data[0] + var bus_name: String = row_data[1] + + var hbox := HBoxContainer.new() + hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) + _container.add_child(hbox) + + var label := Label.new() + label.text = label_text + label.custom_minimum_size = Vector2(150, 0) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", FONT_SIZE) + label.add_theme_color_override("font_color", TEXT_COLOR) + hbox.add_child(label) + + var slider := HSlider.new() + slider.min_value = -40.0 + slider.max_value = 0.0 + slider.step = 0.5 + slider.value = AudioManager.get_volume(bus_name) + slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hbox.add_child(slider) + + var db_label := Label.new() + db_label.text = _format_db(slider.value) + db_label.custom_minimum_size = Vector2(70, 0) + db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + db_label.add_theme_font_size_override("font_size", FONT_SIZE) + db_label.add_theme_color_override("font_color", TEXT_COLOR) + hbox.add_child(db_label) + + slider.value_changed.connect(func(value: float) -> void: + AudioManager.set_volume(bus_name, value) + db_label.text = _format_db(value) + ) + + # Spacer + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(0, 8) + _container.add_child(spacer) + + # Close button + var close_btn := Button.new() + close_btn.text = "CLOSE" + close_btn.add_theme_font_size_override("font_size", FONT_SIZE) + close_btn.pressed.connect(close) + _container.add_child(close_btn) + + +func _destroy_ui() -> void: + if _container: + _container.queue_free() + _container = null + + +func _draw() -> void: + if not _active: + return + var viewport_size := get_viewport_rect().size + + # Dim overlay + draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR) + + # Dialog box + var box_pos := Vector2( + (viewport_size.x - BOX_WIDTH) / 2.0, + (viewport_size.y - BOX_HEIGHT) / 2.0 + ) + var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT)) + draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95)) + draw_rect(box_rect, BORDER_COLOR, false, 1.0) + + # Title + var font := ThemeDB.fallback_font + draw_string(font, + box_pos + Vector2(PADDING, PADDING + 18), + "AUDIO SETTINGS", + HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR) + + +static func _format_db(db: float) -> String: + if db <= -40.0: + return "mute" + return "%d dB" % int(db) diff --git a/client/ui/settings_dialog.tscn b/client/ui/settings_dialog.tscn new file mode 100644 index 000000000..77cf97319 --- /dev/null +++ b/client/ui/settings_dialog.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"] + +; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle +[node name="SettingsDialog" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_settings") 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", From 513682522feb183225368a1c4a88ac85111a2504 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:19:32 +0100 Subject: [PATCH 06/14] test(audio): Sprint 13 test suite + lambda capture fix (#528-#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 57 new tests in test_audio_sprint13.gd covering bus setup, dip profiles, zone crossfade, recognition chime, and murmur wiring. Fix lambda capture bug in 3 signal tests (String → Array wrapper for reliable closure capture in Godot 4). Add bus volume reset to before_test() for test isolation against prefs-loaded state. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_audio_bus_routing.gd | 82 +++- client/tests/test_audio_sprint13.gd | 560 +++++++++++++++++++++++++ 2 files changed, 633 insertions(+), 9 deletions(-) create mode 100644 client/tests/test_audio_sprint13.gd diff --git a/client/tests/test_audio_bus_routing.gd b/client/tests/test_audio_bus_routing.gd index f0df0eb2b..8dda3e962 100644 --- a/client/tests/test_audio_bus_routing.gd +++ b/client/tests/test_audio_bus_routing.gd @@ -15,10 +15,16 @@ extends GdUnitTestSuite func before_test() -> void: AudioManager.clear_dip() + for bus in AudioManager.BUSES: + AudioManager.set_volume(bus, 0.0) + GameState.stationary_ticks = 0 + GameState._prev_player_position = Vector2(-1e9, -1e9) func after_test() -> void: AudioManager.clear_dip() + GameState.stationary_ticks = 0 + GameState._prev_player_position = Vector2(-1e9, -1e9) # ============================================================================== @@ -171,22 +177,25 @@ func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void: func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void: ## apply_dip() must emit dip_changed(profile) synchronously. - var received_profile := "" - var conn := func(p: String) -> void: received_profile = p + ## Array wrapper used for lambda capture — GDScript 4 captures String locals by value, + ## so a mutable reference type is required to observe signal argument inside the closure. + var received := [""] + var conn := func(p: String) -> void: received[0] = p AudioManager.dip_changed.connect(conn) AudioManager.apply_dip("dialogue") AudioManager.dip_changed.disconnect(conn) - assert_that(received_profile).is_equal("dialogue") + assert_that(received[0]).is_equal("dialogue") func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void: ## clear_dip() must emit dip_changed("") to signal audio restored. + ## Array wrapper used for lambda capture — same reason as apply_dip signal test above. AudioManager.apply_dip("dialogue") - var received_profile := "sentinel" - var conn := func(p: String) -> void: received_profile = p + var received := ["sentinel"] + var conn := func(p: String) -> void: received[0] = p AudioManager.dip_changed.connect(conn) AudioManager.clear_dip() AudioManager.dip_changed.disconnect(conn) - assert_that(received_profile).is_equal("") + assert_that(received[0]).is_equal("") func test_audio_manager_apply_dip_interrupts_previous() -> void: ## Switching profiles mid-dip: active profile must update to the new one. @@ -196,13 +205,14 @@ func test_audio_manager_apply_dip_interrupts_previous() -> void: func test_audio_manager_dip_changed_fires_on_profile_switch() -> void: ## Switching from dialogue to confrontation emits dip_changed("confrontation"). + ## Array wrapper used for lambda capture — same reason as apply_dip signal test above. AudioManager.apply_dip("dialogue") - var received_profile := "" - var conn := func(p: String) -> void: received_profile = p + var received := [""] + var conn := func(p: String) -> void: received[0] = p AudioManager.dip_changed.connect(conn) AudioManager.apply_dip("confrontation") AudioManager.dip_changed.disconnect(conn) - assert_that(received_profile).is_equal("confrontation") + assert_that(received[0]).is_equal("confrontation") func test_audio_manager_has_asset_false_for_unknown_key() -> void: ## has_asset() must return false for a key that was never registered. @@ -291,3 +301,57 @@ func test_audio_manager_play_sound_event_noop_for_empty_type() -> void: ## #125: Empty event type string must be a no-op. AudioManager.play_sound_event("", Vector2(5.0, 5.0)) # No assertion needed — absence of crash is the test. + + +# ============================================================================== +# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus +# ============================================================================== + +func test_d071_stationary_ticks_increments_when_position_unchanged() -> void: + ## D-071: stationary_ticks must increment on each snapshot where player doesn't move. + var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + GameState.apply_snapshot({"tick": 1, "entities": [player_entity]}) + GameState.apply_snapshot({"tick": 2, "entities": [player_entity]}) + GameState.apply_snapshot({"tick": 3, "entities": [player_entity]}) + assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3) + +func test_d071_stationary_ticks_resets_on_movement() -> void: + ## D-071: stationary_ticks must reset to 0 when the player position changes. + var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + GameState.apply_snapshot({"tick": 1, "entities": [pos_a]}) + GameState.apply_snapshot({"tick": 2, "entities": [pos_a]}) + assert_that(GameState.stationary_ticks).is_equal(1) + GameState.apply_snapshot({"tick": 3, "entities": [pos_b]}) + assert_that(GameState.stationary_ticks).is_equal(0) + +func test_d071_stationary_ticks_reaches_threshold() -> void: + ## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation. + var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + for tick in range(31): + GameState.apply_snapshot({"tick": tick, "entities": [player_entity]}) + assert_that(GameState.stationary_ticks >= 30).is_true() + + +# ============================================================================== +# Layer 7: D-069 (#530) — Dialogue dip profile interaction +# ============================================================================== + +func test_d069_dialogue_dip_overridden_by_confrontation() -> void: + ## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts). + AudioManager.apply_dip("dialogue") + assert_that(AudioManager.get_active_dip()).is_equal("dialogue") + AudioManager.apply_dip("confrontation") + assert_that(AudioManager.get_active_dip()).is_equal("confrontation") + +func test_d069_listening_focus_cleared_by_dialogue() -> void: + ## D-069: Dialogue dip must override listening_focus (higher priority focus state). + AudioManager.apply_dip("listening_focus") + assert_that(AudioManager.get_active_dip()).is_equal("listening_focus") + AudioManager.apply_dip("dialogue") + assert_that(AudioManager.get_active_dip()).is_equal("dialogue") + +func test_d069_clear_dip_noop_when_empty() -> void: + ## D-069: clear_dip() when no dip active must be a safe no-op. + AudioManager.clear_dip() # Should not crash + assert_that(AudioManager.get_active_dip()).is_equal("") diff --git a/client/tests/test_audio_sprint13.gd b/client/tests/test_audio_sprint13.gd new file mode 100644 index 000000000..f8a3cc79d --- /dev/null +++ b/client/tests/test_audio_sprint13.gd @@ -0,0 +1,560 @@ +## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073). +## +## Spec refs: +## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion. +## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds). +## D-069: Audio dip profiles — timing values, dB offsets, interruptibility. +## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility). +## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants. +## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible. +## +## Tickets covered: +## #529 — Zone crossfade implementation (set_zone body + tween timing) +## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring) +## #531 — Recognition chime fires at cognitive delay onset +## #533 — NPC conversation murmur wired to WorldSFX bus +## +## Test layers: +## 1. Zone crossfade spec and API (D-073 / #529) +## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing) +## 3. Volume slider proportional dip (D-068 / D-069) +## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530) +## 5. Recognition chime onset verification (D-067 / #531) +## 6. NPC murmur routing (D-072 / #533) +## 7. AudioManager no-op fallback sanity (D-068 / D-038) +class_name TestAudioSprint13 +extends GdUnitTestSuite + + +func before_test() -> void: + AudioManager.clear_dip() + AudioManager.stop_all_loops() + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0) + AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0) + AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0) + AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0) + + +func after_test() -> void: + AudioManager.clear_dip() + AudioManager.stop_all_loops() + + +# ============================================================================== +# Layer 1: Zone Crossfade — D-073 / #529 +# set_zone() stub exists now; full implementation lands in #529. +# ============================================================================== + +func test_d073_set_zone_method_exists() -> void: + ## D-073 / #529: AudioManager must expose set_zone(zone_id: String). + assert_that(AudioManager.has_method("set_zone")).is_true() + + +func test_d073_set_zone_hub_does_not_crash() -> void: + ## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation. + AudioManager.set_zone("hub") + + +func test_d073_set_zone_bar_does_not_crash() -> void: + ## D-073: set_zone("bar") is a safe call. + AudioManager.set_zone("bar") + + +func test_d073_set_zone_corridor_does_not_crash() -> void: + ## D-073: set_zone("corridor") is a safe call. + AudioManager.set_zone("corridor") + + +func test_d073_set_zone_empty_string_does_not_crash() -> void: + ## D-073: set_zone("") edge case — no zone ID. Must not crash. + AudioManager.set_zone("") + + +func test_d073_set_zone_unknown_zone_does_not_crash() -> void: + ## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op. + AudioManager.set_zone("nonexistent_zone_xyz") + + +func test_d073_zone_asset_hub_key_matches_filename_convention() -> void: + ## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief. + ## hub/workplace → amb_hub_layer (must match filename stem in res://audio/). + ## Test verifies naming convention is documentable. Activates once #529 adds the map. + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("hub")).is_true() + assert_that(zone_assets["hub"]).is_equal("amb_hub_layer") + + +func test_d073_zone_asset_bar_key_matches_filename_convention() -> void: + ## D-073 / #529: bar → amb_bar_layer + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("bar")).is_true() + assert_that(zone_assets["bar"]).is_equal("amb_bar_layer") + + +func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void: + ## D-073 / #529: smuggling corridor → amb_corridor_layer + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test") + return + var zone_assets: Dictionary = AudioManager.ZONE_ASSETS + assert_that(zone_assets.has("corridor")).is_true() + assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer") + + +func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void: + ## D-073: Crossfade tween duration must be 1.5-2.0s. + ## Activates once #529 defines the duration constant. + if not "CROSSFADE_DURATION" in AudioManager: + push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test") + return + var duration: float = AudioManager.CROSSFADE_DURATION + assert_that(duration >= 1.5 and duration <= 2.0).is_true() + + +func test_d073_set_zone_same_zone_repeated_is_noop() -> void: + ## D-073: Crossing back to the current zone should not restart a crossfade. + ## (No audio pops when zone boundary is ambiguous.) Activates post-#529. + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test") + return + AudioManager.set_zone("hub") + AudioManager.set_zone("hub") + ## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens). + ## Stub: assert no crash and ambient_players size is 0 or 1, not 2. + assert_that(AudioManager._ambient_players.size() <= 1).is_true() + + +# ============================================================================== +# Layer 2: D-069 Dip Timing and dB Spec Values +# These tests supplement test_audio_bus_routing.gd with timing and dB precision. +# ============================================================================== + +func test_d069_dialogue_ease_in_is_300ms() -> void: + ## D-069: Dialogue dip ease-in = 300ms (0.3s). + var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"] + assert_that(ease_in).is_equal_approx(0.3, 0.05) + + +func test_d069_dialogue_ease_out_is_500ms() -> void: + ## D-069: Dialogue dip ease-out = 500ms (0.5s). + var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"] + assert_that(ease_out).is_equal_approx(0.5, 0.05) + + +func test_d069_confrontation_ease_in_is_500ms() -> void: + ## D-069: Confrontation dip ease-in = 500ms (0.5s). + var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"] + assert_that(ease_in).is_equal_approx(0.5, 0.05) + + +func test_d069_confrontation_ease_out_is_1000ms() -> void: + ## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion. + var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"] + assert_that(ease_out).is_equal_approx(1.0, 0.05) + + +func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void: + ## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used. + var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"] + assert_that(dip >= -8.0 and dip <= -6.0).is_true() + + +func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void: + ## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used. + var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"] + assert_that(dip >= -12.0 and dip <= -10.0).is_true() + + +func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void: + ## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through). + var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"] + assert_that(dip >= -6.0 and dip <= -4.0).is_true() + + +func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void: + ## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus). + var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"] + assert_that(boost >= 2.0 and boost <= 3.0).is_true() + + +func test_d069_dialogue_spec_only_affects_ambient_bus() -> void: + ## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music + ## must NOT appear in the spec — world events remain audible during conversation. + var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"] + assert_that(buses.has("Ambient")).is_true() + assert_that(buses.has("WorldSFX")).is_false() + assert_that(buses.has("PlayerActions")).is_false() + assert_that(buses.has("UISounds")).is_false() + assert_that(buses.has("Music")).is_false() + + +func test_d069_confrontation_spec_does_not_affect_player_actions() -> void: + ## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled. + var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"] + assert_that(buses.has("PlayerActions")).is_false() + + +func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void: + ## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible. + var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"] + assert_that(buses.has("UISounds")).is_false() + + +func test_d069_listening_focus_spec_does_not_affect_ambient() -> void: + ## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified. + ## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip. + var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"] + assert_that(buses.has("Ambient")).is_false() + + +func test_d069_filter_cutoff_default_is_approx_20khz() -> void: + ## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed. + ## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz. + assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true() + + +func test_d069_confrontation_filter_hz_is_800hz() -> void: + ## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070). + var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"] + assert_that(filter_hz).is_equal_approx(800.0, 50.0) + + +# ============================================================================== +# Layer 3: Volume Slider Proportional Dip — D-068 / D-069 +# ============================================================================== + +func test_d068_set_volume_get_volume_roundtrip() -> void: + ## D-068: set_volume / get_volume roundtrip preserves the slider value. + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01) + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + + +func test_d068_set_volume_persists_across_all_buses() -> void: + ## D-068: Each of the 5 buses has an independent volume setting. + AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0) + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0) + AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0) + AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0) + AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01) + + +func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void: + ## D-069: get_volume() always returns the player slider setting (base). + ## The effective AudioServer volume during a dip = base + offset_db. + ## Callers storing the slider value must always read get_volume(), not AudioServer directly. + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.apply_dip("dialogue") + ## Even during dip, get_volume returns the base (not base + dip offset). + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01) + + +func test_d069_set_volume_during_active_dip_updates_base() -> void: + ## D-069: Changing slider mid-dip must update the base so the proportional + ## calculation uses the new slider value (not the pre-dip value). + AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0) + AudioManager.apply_dip("dialogue") + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0) + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01) + + +func test_d069_clear_dip_does_not_change_stored_slider_value() -> void: + ## D-069: clear_dip() restores AudioServer volumes to base, but get_volume() + ## must still reflect the player slider setting (not the dipped value). + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0) + AudioManager.apply_dip("dialogue") + AudioManager.clear_dip() + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01) + + +# ============================================================================== +# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530 +# +# These tests verify GameState has the snapshot fields needed for #530 wiring. +# The assertions on AudioManager dip activation are stubbed pending implementation. +# ============================================================================== + +func test_d069_game_state_current_dialogue_field_exists() -> void: + ## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip. + ## Field must exist so #530 wiring can check it. + assert_that("current_dialogue" in GameState).is_true() + + +func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void: + ## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": { + "npc_name": "Kael", "npc_entity_id": 2, + "speech": "Haven't seen you around.", "options": [], + }, + }) + assert_that(GameState.current_dialogue != null).is_true() + assert_that(GameState.current_dialogue is Dictionary).is_true() + GameState.apply_snapshot({"tick": 2}) + + +func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void: + ## #530 precondition: Snapshot without current_dialogue → current_dialogue is null. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + GameState.apply_snapshot({"tick": 2}) + assert_that(GameState.current_dialogue == null).is_true() + + +func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void: + ## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires. + ## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state. + AudioManager.clear_dip() + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + ## Precondition: dialogue IS active in GameState. + assert_that(GameState.current_dialogue != null).is_true() + ## ASSERTION (activate once #530 is implemented): + ## assert_that(AudioManager.get_active_dip()).is_equal("dialogue") + GameState.apply_snapshot({"tick": 2}) + AudioManager.clear_dip() + + +func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void: + ## D-069 / #530: When current_dialogue returns to null, clear_dip() fires. + ## TODO(#530): Uncomment the assertion once call site is wired. + GameState.apply_snapshot({ + "tick": 1, + "current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []}, + }) + GameState.apply_snapshot({"tick": 2}) # dialogue ends + ## ASSERTION (activate once #530 is implemented): + ## assert_that(AudioManager.get_active_dip()).is_equal("") + assert_that(GameState.current_dialogue == null).is_true() + + +func test_d071_listening_focus_gate_is_30_ticks() -> void: + ## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks. + ## The tick gate is the CALLER's responsibility per AudioManager comment. + ## This test documents the threshold so it doesn't silently drift. + ## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530. + const LISTENING_FOCUS_TICK_GATE := 30 + ## Stub: verify the spec value is documented. + assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30) + + +func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void: + ## D-070: Confrontation muffling is felt, not announced. No UI indicator. + ## Verify AudioManager does not expose a confrontation_ui_shown signal. + var signals: Array = AudioManager.get_signal_list().map( + func(s: Dictionary) -> String: return s.name + ) + assert_that(signals.has("confrontation_ui_shown")).is_false() + assert_that(signals.has("listening_focus_shown")).is_false() + + +# ============================================================================== +# Layer 5: Recognition Chime Onset — D-067 / #531 +# +# Chime fires at ONSET of cognitive delay (when entity FIRST appears in +# pending_recognitions), NOT at completion (when it leaves). +# ============================================================================== + +func test_d067_chime_recognition_constant_defined() -> void: + ## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant. + assert_that("CHIME_RECOGNITION" in AudioManager).is_true() + + +func test_d067_chime_recognition_matches_d038_asset_key() -> void: + ## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel. + ## Key must match filename stem in res://audio/. + assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime") + + +func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void: + ## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default. + ## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX. + ## Verify play() default bus is UISounds (the chime caller uses the default). + ## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct. + assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS) + + +func test_d067_play_chime_recognition_noop_when_asset_absent() -> void: + ## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent. + ## Client must not crash when audio branch has not yet provided the .ogg file. + AudioManager.play(AudioManager.CHIME_RECOGNITION) + ## No assertion needed — absence of crash is the test. + + +func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void: + ## D-067: "Onset" of cognitive delay = first frame an entity appears in + ## pending_recognitions, at remaining_ticks == total_delay_ticks. + ## This is the moment the chime must fire. + GameState.apply_snapshot({ + "tick": 1, + "pending_recognitions": [ + {"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0, + "remaining_ticks": 6, "total_delay_ticks": 6}, + ], + }) + assert_that(GameState.pending_recognitions.size()).is_equal(1) + var rec: Dictionary = GameState.pending_recognitions[0] + ## Onset condition: remaining == total (delay just started) + assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks) + GameState.apply_snapshot({"tick": 2}) + + +func test_d067_completion_is_when_entity_absent_from_pending() -> void: + ## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions. + ## The chime must NOT fire at this point — it fired at onset. + GameState.apply_snapshot({ + "tick": 1, + "pending_recognitions": [ + {"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0, + "remaining_ticks": 1, "total_delay_ticks": 6}, + ], + }) + assert_that(GameState.pending_recognitions.size()).is_equal(1) + ## Completion: entity removed from array + GameState.apply_snapshot({"tick": 2, "pending_recognitions": []}) + assert_that(GameState.pending_recognitions.size()).is_equal(0) + ## Chime state: AudioManager must not have a dip triggered by recognition. + ## (Chime is a play() call, not a dip — this verifies no side effects on dip state.) + assert_that(AudioManager.get_active_dip()).is_equal("") + + +func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void: + ## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd + ## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop. + ## This test verifies FogEntities correctly identifies the onset condition. + ## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay. + var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0, + "remaining_ticks": 6, "total_delay_ticks": 6} + var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0, + "remaining_ticks": 3, "total_delay_ticks": 6} + ## Frame 1: entity APPEARS (onset — chime should fire here) + GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]}) + assert_that(GameState.pending_recognitions[0].remaining_ticks + == GameState.pending_recognitions[0].total_delay_ticks).is_true() + ## Frame 2: entity mid-progress (chime must NOT re-fire) + GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]}) + assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3) + ## Frame 3: entity completes (chime must NOT fire) + GameState.apply_snapshot({"tick": 3, "pending_recognitions": []}) + assert_that(GameState.pending_recognitions.size()).is_equal(0) + + +func test_d067_chime_duration_spec_is_300_to_400ms() -> void: + ## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration. + ## This test documents the spec range so asset authoring can be validated. + ## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should + ## return a value in this range. + const CHIME_MIN_DURATION := 0.3 + const CHIME_MAX_DURATION := 0.4 + if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION): + push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test") + return + var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION) + if stream == null: + push_warning("TestAudioSprint13: could not retrieve chime stream from registry") + return + assert_that(stream.get_length() >= CHIME_MIN_DURATION + and stream.get_length() <= CHIME_MAX_DURATION).is_true() + + +# ============================================================================== +# Layer 6: NPC Murmur Routing — D-072 / #533 +# Single universal asset, WorldSFX bus, no zone-specific variants. +# ============================================================================== + +func test_d072_world_sfx_bus_is_correct_for_murmur() -> void: + ## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture. + ## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined + ## by that bus's noise floor relative to the murmur volume. + assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX") + + +func test_d072_play_at_method_accepts_bus_parameter() -> void: + ## D-072 / #533: play_at() must accept an optional bus string parameter. + ## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX). + assert_that(AudioManager.has_method("play_at")).is_true() + + +func test_d072_play_at_noop_when_murmur_asset_absent() -> void: + ## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532). + ## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op. + AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX) + ## No assertion — absence of crash is the test. + + +func test_d072_play_noop_when_murmur_asset_absent() -> void: + ## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully. + AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX) + ## No assertion — absence of crash is the test. + + +func test_d072_no_zone_specific_murmur_variants() -> void: + ## D-072: SINGLE universal murmur asset — no zone-specific variants. + ## "One murmur asset + zone-dependent conspicuousness creates the signal/noise + ## dynamic naturally." Zone-specific variants MUST NOT exist in the registry. + assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false() + assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false() + + +func test_d072_murmur_does_not_use_ambient_bus() -> void: + ## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background). + ## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient. + ## Verify bus constant distinction. + assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX) + + +# ============================================================================== +# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038 +# ============================================================================== + +func test_d068_registry_size_is_non_negative() -> void: + ## D-068: Registry is empty when res://audio/ is absent, non-negative always. + assert_that(AudioManager.get_registry_size() >= 0).is_true() + + +func test_d068_play_loop_returns_null_for_missing_asset() -> void: + ## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash). + var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc") + assert_that(result == null).is_true() + + +func test_d068_stop_loop_noop_for_unknown_key() -> void: + ## D-068: stop_loop() on a key never started — no crash, no error. + AudioManager.stop_loop("nonexistent_key_xyzabc") + + +func test_d068_stop_all_loops_when_none_playing() -> void: + ## D-068: stop_all_loops() with no active ambient players — no crash. + AudioManager.stop_all_loops() + + +func test_d068_has_asset_returns_false_for_unknown_key() -> void: + ## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key. + assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false() + + +func test_d068_play_noop_does_not_change_dip_state() -> void: + ## D-068: play() on a missing asset must not modify the dip state machine. + ## Verifies no-op fallback has zero side effects. + AudioManager.apply_dip("dialogue") + AudioManager.play("nonexistent_asset_xyzabc") + assert_that(AudioManager.get_active_dip()).is_equal("dialogue") From dd1dd0feec8823a45d8d288d60bba63b25fd853a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:22:48 +0100 Subject: [PATCH 07/14] =?UTF-8?q?fix(simulation):=20address=20PR=20#46=20r?= =?UTF-8?q?eview=20=E2=80=94=20zone=20tests,=20doc=20accuracy,=20TBD=20com?= =?UTF-8?q?ment?= 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. From f57560ebe7a796dbfddfd682bc40711dee275ff3 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:34:39 +0100 Subject: [PATCH 08/14] docs(meta): add tea comment hanging workaround to CLAUDE.md tea comment hangs with inline heredocs and multi-line strings. Document the two-step pattern: write to .tmp/ first, then pass via $(cat). Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index e98de60c5..018623bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,12 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open Key rules: - **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY) - **Use `--output simple`** for machine-readable output (no table borders) +- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`: + ```bash + # Step 1: Write content to .tmp/ (gitignored) using the Write tool + # Step 2: Post via cat + tea comment --login schweitz --repo jpmschweitzer/settled-reach "$(cat .tmp/review-branch.md)" + ``` - **`tea pr reject` does not work on your own PRs** — use `tea comment` instead - **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches. From 8985340bdd402df8574b4d4227a02fe2634e4df2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:37:23 +0100 Subject: [PATCH 09/14] =?UTF-8?q?fix(client):=20address=20PR=20#47=20revie?= =?UTF-8?q?w=20=E2=80=94=20murmur=20deferral,=20zone=20extraction,=206=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove premature Voice/VoiceConversation from SOUND_EVENT_ASSETS (D-072 defers murmur to end-to-end sprint). Extract current_zone_id in GameState.apply_snapshot() as first-class field, eliminating O(N) tile scan in main.gd (D-020 server-authoritative). Add dir.list_dir_end() after registry scan. Add rapid zone-crossing + _load_prefs() roundtrip tests. Enhance comments on hub/workplace same-asset pattern, station base hum, and confrontation dip replacement semantics. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/audio_manager.gd | 12 ++-- client/scripts/autoloads/game_state.gd | 16 +++++ client/scripts/main.gd | 14 +---- client/tests/test_audio_sprint13.gd | 74 +++++++++++++++++++++++ client/ui/dialogue_box.gd | 4 +- 5 files changed, 103 insertions(+), 17 deletions(-) diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index ba35c5b20..7aab28d75 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -45,7 +45,12 @@ const FILTER_CUTOFF_DEFAULT := 20500.0 const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range # Maps server zone_id strings to ambient asset keys (filenames in res://audio/). -# Hub and Workplace share the same ambient layer — same location type. +# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) — +# they are the same location type, so hub→workplace transition is a same-asset no-op +# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates +# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1. +# Note: amb_station_base (D-038 global base hum) plays globally via play_loop() +# at startup — it is not zone-dependent and has no ZONE_ASSETS entry. const ZONE_ASSETS: Dictionary = { "hub": "amb_hub_layer", "workplace": "amb_hub_layer", @@ -129,6 +134,7 @@ func _scan_dir(path: String) -> void: if stream: _registry[file_name.get_basename()] = stream file_name = dir.get_next() + dir.list_dir_end() func has_asset(asset_key: String) -> bool: @@ -201,10 +207,6 @@ const SOUND_EVENT_ASSETS: Dictionary = { "FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands "FootstepSprint": "sfx_footstep_metal_run", "FootstepRun": "sfx_footstep_metal_run", - # D-072 (#533): NPC conversation murmur — single universal asset, World SFX bus. - # Zone ambient provides natural conspicuousness (bar buries it, corridor exposes it). - "Voice": "sfx_npc_murmur", - "VoiceConversation": "sfx_npc_murmur", } diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 2b912d2e5..19187264f 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -68,6 +68,11 @@ var close_sound_events: Array = [] var stationary_ticks: int = 0 var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position +# D-073 (#529): Server-authoritative zone_id from the player's current tile. +# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review. +# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet). +var current_zone_id: String = "" + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -197,6 +202,17 @@ func apply_snapshot(snapshot: Dictionary) -> void: medium_sound_events = [] close_sound_events = [] + # D-073 (#529): Extract zone_id from the player's current tile (server-authoritative). + # O(1) via visible_positions dict would be ideal, but tiles are arrays without + # positional indexing — use the same tile iteration below instead. + current_zone_id = "" + var _px := int(player_position.x) + var _py := int(player_position.y) + for _ztile in visible_tiles: + if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py: + current_zone_id = _ztile.get("zone_id", "") + break + # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 16b4eb2dd..c7ab84c59 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -254,19 +254,11 @@ func _play_recognition_chimes() -> void: _known_recognition_ids.erase(eid) -# D-073 (#529): Zone ambient crossfade — read zone_id from player's current tile. +# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id +# (extracted in apply_snapshot(), server-authoritative per D-020). # Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade). -# zone_id field is server-authoritative; missing field treated as empty (no zone). func _update_zone() -> void: - var px := int(GameState.player_position.x) - var py := int(GameState.player_position.y) - var zone: String = "" - for tile in GameState.visible_tiles: - if not tile is Dictionary: - continue - if tile.get("x") == px and tile.get("y") == py: - zone = tile.get("zone_id", "") - break + var zone := GameState.current_zone_id if zone != _current_zone: _current_zone = zone AudioManager.set_zone(zone) diff --git a/client/tests/test_audio_sprint13.gd b/client/tests/test_audio_sprint13.gd index f8a3cc79d..b7580ad34 100644 --- a/client/tests/test_audio_sprint13.gd +++ b/client/tests/test_audio_sprint13.gd @@ -108,6 +108,35 @@ func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void: assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer") +func test_d073_game_state_extracts_zone_id_from_player_tile() -> void: + ## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as + ## a first-class field (like player_facing, player_stance), avoiding O(N) tile + ## scan in main.gd per D-020 server-authoritative state. + var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + GameState.apply_snapshot({ + "tick": 1, + "entities": [player], + "tiles": [ + {"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"}, + {"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"}, + ], + }) + assert_that(GameState.current_zone_id).is_equal("bar") + GameState.apply_snapshot({"tick": 2}) + + +func test_d073_game_state_zone_id_empty_when_field_absent() -> void: + ## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09). + var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}} + GameState.apply_snapshot({ + "tick": 1, + "entities": [player], + "tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}], + }) + assert_that(GameState.current_zone_id).is_equal("") + GameState.apply_snapshot({"tick": 2}) + + func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void: ## D-073: Crossfade tween duration must be 1.5-2.0s. ## Activates once #529 defines the duration constant. @@ -131,6 +160,51 @@ func test_d073_set_zone_same_zone_repeated_is_noop() -> void: assert_that(AudioManager._ambient_players.size() <= 1).is_true() +func test_d073_rapid_zone_crossing_interruptible() -> void: + ## D-073: Rapid back-and-forth zone crossing (interruptible crossfade). + ## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate + ## volume — new tween starts from current position. No stacked tweens, no crash. + if not "ZONE_ASSETS" in AudioManager: + push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test") + return + # Cross hub → bar → hub rapidly (simulates player walking back and forth) + AudioManager.set_zone("hub") + AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween + AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween + AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween + ## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in). + ## No stacked tweens — _kill_zone_tweens clears previous tweens each time. + assert_that(AudioManager._ambient_players.size() <= 2).is_true() + ## Zone tweens array should only contain active tweens from the last set_zone call. + assert_that(AudioManager._zone_tweens.size() <= 2).is_true() + AudioManager.stop_all_loops() + + +func test_d068_load_prefs_persistence_roundtrip() -> void: + ## #528: Volume persistence — save/load roundtrip via ConfigFile. + ## Sets non-default volumes, saves, reloads, and verifies values match. + AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5) + AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0) + AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0) + ## _save_prefs() fires inside set_volume() — prefs file is written. + ## Reload prefs by calling _load_prefs() directly. + AudioManager._load_prefs() + assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01) + assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01) + ## Reset to defaults for subsequent tests. + for bus in AudioManager.BUSES: + AudioManager.set_volume(bus, 0.0) + + +func test_d068_load_prefs_handles_missing_config_gracefully() -> void: + ## #528: _load_prefs() with a missing/corrupted config file must not crash. + ## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early. + ## This test documents the graceful fallback behavior. + AudioManager._load_prefs() + ## No assertion — absence of crash is the test. + + # ============================================================================== # Layer 2: D-069 Dip Timing and dB Spec Values # These tests supplement test_audio_bus_routing.gd with timing and dB precision. diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index e64363929..62eb59c8c 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -140,7 +140,9 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading # D-069: Dialogue dip — reduce ambient noise to foreground conversation. - # Confrontation options override this with apply_dip("confrontation") in _start_confrontation_beat(). + # Confrontation options REPLACE (not nest) this dip via apply_dip("confrontation") + # in _start_confrontation_beat(). hide_dialogue()'s clear_dip() restores to base + # volumes regardless of which profile was last active — intentional replacement semantics. AudioManager.apply_dip("dialogue") # D-061: auto-pause in single-player when dialogue opens From e11d54ff5c4e8b17d7d3a63c4896d0ecca9732f9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 18:48:42 +0100 Subject: [PATCH 10/14] fix(assets): add missing .import file for sfx_npc_murmur.ogg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Godot requires .import files for stable uid:// references across editor sessions. All other audio assets have one — this was missed in the audio branch PR. Co-Authored-By: Claude Opus 4.6 --- client/assets/audio/sfx_npc_murmur.ogg.import | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 client/assets/audio/sfx_npc_murmur.ogg.import diff --git a/client/assets/audio/sfx_npc_murmur.ogg.import b/client/assets/audio/sfx_npc_murmur.ogg.import new file mode 100644 index 000000000..4ae76c091 --- /dev/null +++ b/client/assets/audio/sfx_npc_murmur.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://caeuztw9ah5h5l" +path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr" + +[deps] + +source_file="res://assets/audio/sfx_npc_murmur.ogg" +dest_files=["res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 From f6eb92cb2500b0948dec666bb3c78d2d6476cbee Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 19:52:44 +0100 Subject: [PATCH 11/14] =?UTF-8?q?feat(docs):=20add=20D-078=20=E2=80=94=20o?= =?UTF-8?q?verheard=20NPC=20conversation=20via=20passive=20dialogue=20pane?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-authoritative stochastic per-word occlusion filter for NPC-to-NPC conversations. Dialogue panel in passive mode displays pre-occluded text; monologue remains a clean internal response system. Indexes updated. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- decisions/README.md | 2 +- decisions/perception.md | 26 +++++++++++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 018623bd0..21e789065 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ db/ decisions/ # Decision domain files (source of truth) README.md # Domain index and query examples architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066 - perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061 + perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078 content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064 scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065 process.md # D-004, D-021, D-022 diff --git a/decisions/README.md b/decisions/README.md index 2572a0042..98ca46d9a 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -11,7 +11,7 @@ Cross-domain decisions live in one file with cross-reference notes in related fi | File | Domain | Decisions | |------|--------|-----------| | [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073 | -| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072 | +| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078 | | [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074 | | [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065 | | [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 | diff --git a/decisions/perception.md b/decisions/perception.md index c352b9d12..a50753e03 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -371,6 +371,30 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Raised by:** Tyre (architecture review, #523) - **Dissent:** None +### D-078: Overheard NPC conversation — passive dialogue panel with occlusion filter +- **Date:** 2026-02-19 +- **Decision:** NPC-to-NPC conversations overheard by the player are displayed in the **dialogue panel in passive mode** (read-only, no response options). An **occlusion filter** degrades displayed text based on distance, ambient noise, and player effort (ListeningFocus stance). Monologue remains a separate channel — it reacts to overheard content via the `witness_interaction` trigger but does not carry conversation text itself. +- **Passive panel behavior:** + - Same D-061 dialogue box, but read-only — no response options rendered. + - Shows speaker name and intended target (e.g. "Kael → Mira"). + - Visually distinguished from interactive dialogue (dimmed border or header treatment — exact style TBD by Araminta). + - Walking out of earshot dismisses the panel naturally. +- **Occlusion filter — server-authoritative:** + - Occlusion is computed **on the server**. The server rolls per-word visibility based on distance, ambient noise, and ListeningFocus stance, then sends pre-occluded text to the client with `...` replacing dropped words. The client renders exactly what it receives — no client-side probability logic. + - Per-word visibility is a **probability**, not a deterministic cutoff. Each word is independently rolled — same distance, same conversation can yield different fragments across playthroughs. + - **Close / clear:** High probability per word — most or all text arrives intact, speaker names shown. + - **Moderate distance / some noise:** Medium probability — words arrive as `...`, speaker names visible. The player may or may not catch the crucial word. + - **Far / noisy zone:** Low probability — speaker indicators only, few or no readable words. Audio murmur still plays per D-072. + - **ListeningFocus stance:** Shifts probability curve upward — improves word visibility at current distance. + - **Multiplayer-readiness:** Each observer gets their own occluded version computed from their specific distance/noise/stance. Seedable RNG per tick ensures deterministic replay (D-010). + - **Design intent:** Stochastic word-drop means the player can never be certain they got the full picture from eavesdropping alone. This pushes them toward direct conversation to confirm — where they must reveal something about what they know. The information asymmetry loop closes on itself. +- **Dual-channel principle:** Dialogue panel = what you hear. Monologue = what you think about it. The `witness_interaction` monologue trigger fires after overheard content is displayed, producing the character's internal reaction ("She's lying to him", "So they do know about the cargo"). These are separate UI elements with separate content pipelines. +- **Content pipeline:** Copy team authors NPC-to-NPC dialogue lines once at full fidelity. The server applies occlusion degradation before sending to the client. No need to author multiple fidelity versions. +- **Rationale:** Keeps monologue as a clean internal response system — thoughts only, never quoted speech. The dialogue panel already exists for player-NPC interaction; reusing it in passive mode for overheard conversation avoids a new UI element. The occlusion filter makes proximity and attention mechanically meaningful without gating content behind binary thresholds. +- **Cross-reference:** Dialogue box ([D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits)), eavesdropping ([D-071](#d-071-no-ambient-dip-for-eavesdropping--listeningfocus-boost)), conversation murmur ([D-072](#d-072-universal-event-driven-conversation-murmur)), monologue system ([D-016](#d-016-internal-monologue-as-core-perceptionatmosphere-system)), knowledge confidence ([D-041](architecture.md#d-041-knowledge-graph-data-model)) +- **Raised by:** Lead (passive panel directive, occlusion filter, monologue separation) +- **Dissent:** None + --- -*31 decisions. Last updated: 2026-02-19 (D-077: OQ-09 resolved — zone temperature memory server-tracked)* +*32 decisions. Last updated: 2026-02-19 (D-078: overheard NPC conversation — passive dialogue panel with occlusion filter)* From 16bc87c590bc13a296da4c2b45ef270173aa01fc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 19:52:50 +0100 Subject: [PATCH 12/14] =?UTF-8?q?docs(sprints):=20add=20Sprint=2014=20"Liv?= =?UTF-8?q?e"=20briefings=20=E2=80=94=2022=20tickets=20across=204=20teams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPC mood, trust, routine execution, NPC-to-NPC conversations with D-078 occlusion, monologue display, content line pools, and visual specs. Teams: server (7), client (3), copy (6), visual (6). Co-Authored-By: Claude Opus 4.6 --- docs/sprints/sprint-14/client.md | 70 ++++++++++++++++++++ docs/sprints/sprint-14/copy.md | 89 +++++++++++++++++++++++++ docs/sprints/sprint-14/joint.md | 107 +++++++++++++++++++++++++++++++ docs/sprints/sprint-14/server.md | 82 +++++++++++++++++++++++ docs/sprints/sprint-14/visual.md | 101 +++++++++++++++++++++++++++++ 5 files changed, 449 insertions(+) create mode 100644 docs/sprints/sprint-14/client.md create mode 100644 docs/sprints/sprint-14/copy.md create mode 100644 docs/sprints/sprint-14/joint.md create mode 100644 docs/sprints/sprint-14/server.md create mode 100644 docs/sprints/sprint-14/visual.md diff --git a/docs/sprints/sprint-14/client.md b/docs/sprints/sprint-14/client.md new file mode 100644 index 000000000..000f55836 --- /dev/null +++ b/docs/sprints/sprint-14/client.md @@ -0,0 +1,70 @@ +# Sprint 14: Live — Client Tasks + +**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player. + +**Branch:** `client` +**Agents:** Stig (UI/rendering dev), Tyre (architect), Hoshe (QA) + +## Carry-over from Sprint 13 + +None. All Sprint 13 client tickets done. + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #122 | Monologue display — client | — | +| #535 | Passive dialogue panel — overheard NPC conversation display | #247 (server) | +| #511 | F3 debug overlay (deferred — WRONG button covers same data) | #495 done | + +Use `db/connectors/ticket show ` for full details. + +## Key Decisions + +- `decisions/architecture.md` — D-020 (ObserverSnapshot — `current_monologue` field), D-042 (UI microcopy format — YAML autoload for HUD labels) +- `decisions/perception.md` — D-016 (internal monologue — text display, atmosphere function), D-018 (three-range sound model — NPC-to-NPC conversations are Voice events), D-055 (sprint suppresses monologue at 40% rate — client renders what server sends; no client-side suppression needed) +- `decisions/content.md` — D-032 (separate monologue pools per character — client only needs to render; no character branching in display layer), D-035 (tag taxonomy — monologue has `character`, `trigger`, `prerequisite` tags; client ignores, server filters) +- D-078 (overheard NPC conversation — passive dialogue panel, server-authoritative occlusion, pre-occluded text rendering, speaker attribution) + +## Notes + +- **#122 — Monologue display — client:** The server-side monologue trigger system is complete — `MonologueEvent` arrives in `ObserverSnapshot.current_monologue` (field added Sprint 5, `server/src/bridge/types.rs`). The visual spec for monologue display was authored in Sprint 13 (#315, `docs/design/monologue-display-spec.md`). Sprint 14 work: implement the client-side display using that spec. Key requirements from the spec: + - **Position:** Bottom-left of viewport, above the interaction verb list, below the minimap. Not in the dialogue box area (max 20% height, D-061). + - **Typography:** Italic, smaller than dialogue (spec has exact px/pt — read `docs/design/monologue-display-spec.md` §Typography). + - **Stacking:** Queue — if a new monologue arrives while one is displaying, queue it (max depth from spec). Do not overwrite mid-display. Sprint 13 note: "monologue not lost on overwrite" is a P0 test (#477, done). + - **Fade:** Auto-fade after display duration. Duration is embedded in `MonologueEvent.display_duration` (float, seconds). Standard Godot `Tween`. + - **Character differentiation:** Smuggler and detective lines use different text colours — hex values in spec. Client reads the active character from `game_state.gd` (autoload at `client/scripts/autoloads/game_state.gd`) or derives it from the snapshot. The server already partitions monologue pools per character (D-032) so the client receives only the correct character's lines — no client-side branching needed. + - Existing `world_renderer.gd` (`client/scripts/rendering/world_renderer.gd`) is the root scene; monologue display is a new `CanvasLayer` child node or a `Control` node in the HUD layer (z-layer 6 per D-049 rendering stack). Do not put it in the dialogue box scene. + - **Integration with audio:** Monologue display does NOT trigger audio (monologue chime fires at cognitive delay onset from the server's recognition pipeline, not from text display). No client-side audio calls needed here. + - Delivery: `client/scripts/rendering/monologue_display.gd` (or similar), integrated into the HUD scene, reading `ObserverSnapshot.current_monologue`. Tests via Hoshe's gdUnit4 harness: queue management, fade-out timing, character colour, no-overwrite on active display. + +- **#535 — Passive dialogue panel — overheard NPC conversation display:** Consumes `ConversationEvent` from `ObserverSnapshot` (added by server #247). Render overheard NPC-to-NPC conversations in the existing dialogue box in passive (read-only) mode. Per D-078 amendment — occlusion is server-authoritative; this ticket is a pure renderer of pre-occluded text. Key requirements: + - **Speaker attribution:** Display header as "Speaker → Target" (e.g. "Kael → Mira") using `speaker_name` and `target_name` from the event. Use the same attribution label slot as player conversation NPC-name display — no new layout region needed. + - **Pre-occluded text renderer:** Render `occluded_line` as-is. The server has already performed per-word Bernoulli drops and replaced dropped words with `...`. The client performs no stochastic logic — no `clarity_score`, no `randf()`, no per-word processing. Display the received string directly in the passive panel. + - **Read-only mode:** The dialogue box must suppress response options and player input when displaying a passive conversation. No response list, no input capture. Player can still move freely. + - **Dismissal:** On `conversation_end` event (or if player moves far enough that no `ConversationEvent` arrives for the NPC pair), dismiss via the standard 300ms fade (same `Tween` as walk-away fade, #437 done). + - **Monologue isolation:** Monologue display (#122, z-layer 6) is unaffected. Do not route conversation text through the monologue display. The server triggers `witness_interaction` which may produce a monologue — that arrives separately via `current_monologue` and is rendered by #122 normally. + - **Blocked by #247** — `ConversationEvent` struct must exist on `ObserverSnapshot` before this can be wired. Start with a stub event type and placeholder renderer if #247 is not yet landed. + - Delivery: passive mode in `client/scripts/ui/dialogue_box.gd` (or a new `passive_dialogue.gd` sibling), verbatim `occluded_line` render, speaker attribution header, `conversation_end` dismissal. Tests via Hoshe's gdUnit4 harness: attribution renders correctly, `occluded_line` with `...` tokens renders verbatim, panel does not show response options in passive mode, dismisses on `conversation_end`. + +- **#511 — F3 debug overlay (deferred):** This ticket is low priority. The WRONG button (F12, #507 done) already captures snapshot state, text render, and description on demand without per-frame cost. The F3 overlay would duplicate that information as a persistent overlay. Stig recommended deferral in Sprint 12. Only implement this sprint if server tickets finish early and QA explicitly needs real-time overlay data that WRONG button cannot provide. Default: skip. + +## Dependency Chain + +``` +#122 (monologue display) — standalone, no server dependency this sprint +#535 (passive dialogue panel) — blocked by #247 (server, NPC-to-NPC conversation system) +#511 (F3 overlay) — standalone, low priority, implement only if capacity allows +``` + +#122 has no blockers — `current_monologue` in the snapshot has been live since Sprint 5. The spec (#315) is done. This is a pure client rendering task. + +#535 is blocked on #247 landing the `ConversationEvent` struct with `occluded_line: String` on `ObserverSnapshot`. Stig can stub the event type locally and build the renderer against it in parallel; wire-up follows when #247 merges. No stochastic logic needed on the client — render `occluded_line` verbatim. + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section): + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): description" --description "body" --base main --head client +``` diff --git a/docs/sprints/sprint-14/copy.md b/docs/sprints/sprint-14/copy.md new file mode 100644 index 000000000..9e7a5798b --- /dev/null +++ b/docs/sprints/sprint-14/copy.md @@ -0,0 +1,89 @@ +# Sprint 14: Live — Copy Tasks + +**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player. + +**Branch:** `copy` +**Agents:** Mellanie (author), Paula (narrative designer), Gestalt (systems designer) + +## Carry-over from Sprint 13 + +None. All Sprint 13 copy tickets done (#368 knowledge vocabulary). + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #253 | Monologue content architecture | — | +| #120 | Monologue line pool system | — | +| #121 | Character voice variation | — | +| #168 | Tagged line pool structure | #240 done | +| #328 | Access tier shift design document | #261 done | +| #536 | NPC-to-NPC overheard dialogue line pool | — | + +Use `db/connectors/ticket show ` for full details. + +## Key Decisions + +- `decisions/content.md` — D-028 (dialogue architecture — four relational layers; #168 implements Layer 1-2 tag structure), D-032 (separate monologue pools per character — hard partition, no shared lines), D-034 (THE FRIEND — Kael Davan / Sera Venn, all monologue content must be coherent with their arcs), D-035 (converged tag taxonomy — 6 structural + 3 selection + 3 monologue-specific tags), D-036 (Sova Transit District setting — Krenn naming conventions, "functional warmth" tone), D-037 (contraband — unlicensed lattice components, moral ambiguity), D-062 (invisible locked dialogue options — new trust tier content appears silently), D-063 (confrontation mechanics — internal voice, italic first-person options), D-064 (walk-away consequences — KG records incompleteness) +- `decisions/perception.md` — D-016 (internal monologue — four functions: perception bridge, atmosphere, diegetic tutorial, unreliable narrator), D-018 (three-range sound model — NPC-to-NPC conversations are Voice events the player overhears) +- D-078 (overheard NPC conversation — lines must survive partial occlusion; front-load key information, short declarative sentences, avoid pronoun-first openers) + +## Notes + +- **#253 — Monologue content architecture:** Define the authoring contract for all future monologue content. This is a **design document**, not a content file. Output: `docs/design/monologue-content-architecture.md`. Required sections: + - Identity-driven interpretation frame: what each character "cares about, fears, is responsible for" — the interpretive lens that makes the same observation produce different monologue per character. Smuggler: exposure, protection of ring, Kael's loyalty. Detective: institutional duty, personal cost, Sera Venn's reliability. + - Category definitions: perception (translating non-visual senses), atmosphere (environment, mood), tutorial (diegetic hints), observation (NPC-specific). Add a fifth if needed. + - Trigger → category mapping: which triggers (from D-035: `enter_location`, `observe_npc`, `hear_sound`, `observe_anomaly`, `post_conversation`, `discover_evidence`, `witness_interaction`, `time_idle`, `return_visit`) map to which categories. Not 1:1 — one trigger can source multiple categories. + - Volume target: how many lines per trigger type per character for v0.1. Per D-032, all volumes are per-character. + - `prerequisite` map design: what game state conditions gate a line. Examples: `kael_interaction_count >= 1`, `knows_kael_has_secret = true`, `location = maintenance_corridor`. These must be expressible in the KG (D-041 FactIds). + - Mellanie authors, Paula reviews for narrative coherence with THE FRIEND arc (D-034), Gestalt validates prerequisite map is implementable from KG state. + +- **#120 — Monologue line pool system:** Produce the first monologue line pool YAML files. Output: `content/` directory (follow existing content structure — see `server/src/content/mod.rs` and `server/src/content/loader.rs` for load path conventions). File structure per D-032: `monologue-smuggler.yaml`, `monologue-detective.yaml`, with subdirectory per location (`terminal/`, `bar/`, `corridor/`). Required tags per line per D-035 (monologue-specific additions): `character`, `trigger`, `prerequisite` (null if unconditional). All 6 structural tags required: `id`, `text`, `role`, `access`, `trust`, `situation`. For monologue, `role` = `player_character`, `access` = `[public]`, `trust` = `surface` (monologue is the player character's internal voice — no access/trust gating applies; tags must still be present for schema compliance). + - Minimum deliverable for Sprint 14: `enter_location` (2-3 lines per location per character) and `time_idle` (2-3 lines per location per character). Eight locations minimum (hub, bar, corridor × 2 characters = 6; add 2 more for the maintenance corridors or gauntlet-equivalent spaces). + - Line quality bar: these are the first lines the game speaks. Every line must pass Mellanie's voice test. Per D-034 THE FRIEND guidance: "no generation expansion" for THE FRIEND content specifically — apply same standard to monologue that will surface during THE FRIEND arc (Kael/Sera scenes). + +- **#121 — Character voice variation:** Write the trait modifier system for monologue voice. This is a **companion authoring guide** to #253, not a separate system. Output: `docs/design/monologue-voice-guide.md`. Required: + - Smuggler vs detective base voice registers (tone, vocabulary, sentence structure). + - How `PersonalityTraits` (D-024 — `Cautious`, `Bold`, `Honest`, `Deceptive`, `Compassionate`, `Ruthless`, `Curious`, `Incurious`, `Social`, `Reclusive`) modify monologue delivery. Not per-trait scripts — a transformation guide: "a Cautious character says X instead of Y" with 2-3 example rewrites. + - Background-dependent phrasing: Guardian background vs Senator background vs Worker background produce different idioms, references, assumptions. At minimum: two examples per background per character. + - Mood influence (feeds into #323 server-side): how the 8+1 moods (D-035 amendment: `neutral`, `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `focused`) colour monologue delivery. Not separate line pools — the same line can be delivered more tersely when the character is Anxious, more expansively when Content. + - Paula writes the narrative framing, Mellanie writes the example lines, Gestalt validates mood → delivery mappings are mechanically coherent. + +- **#168 — Tagged line pool structure:** Implement the converged D-035 YAML schema as a formal JSON Schema or YAML schema document and update the line previewer CLI. Output: `content/_schema/dialogue-line.schema.json` (or `.yaml`) with all 6 structural + 3 selection + 2 authoring-only tags. Monologue additions (`character`, `trigger`, `prerequisite`) as a separate schema extension or `$ref`. The schema is what the content cross-reference validation (CI ticket #464, done) runs against. Update the line previewer CLI (Sprint 5 `#326`, already done) to validate against the new schema on load. Gestalt leads schema design. Mellanie validates against authoring workflow. The 14 v0.1 situations and 9 v0.1 topics from D-035 must be enumerated in the schema as allowed values. + - Note: `server/src/content/line_pool.rs` already implements the Rust-side `IndexedDialogueLine` type — schema must stay in sync. Cross-reference `server/src/content/line_pool.rs` to confirm enum variants match D-035 enumeration. + +- **#328 — Access tier shift design document:** A design document authored by Paula and Mellanie (no code). Output: `docs/design/access-tier-shifts.md`. Required per ticket: + - Per social site, 2-3 most likely tier transitions per character with specific triggers. + - Smuggler at The Terminal (hub): `insider → hostile` when cover blown; `insider → peer` after routine trust-building. + - Smuggler at The Last Shift (bar): `public → insider` after Kael introduction; `insider → hostile` if contraband conversation overheard. + - Detective at The Terminal: `authority → peer` after collaboration event; `authority → hostile` if confrontation fails. + - Detective at The Last Shift: `public → insider` via Sera Venn; `authority → peer` after buying rounds. + - For each transition: what observable event triggers it, which KG facts are required (`FactId` references from #368 knowledge vocabulary), and whether it is reversible. + - This document feeds #169 (Layer 1 access tier filtering, deferred to S15) as its design input. + +- **#536 — NPC-to-NPC overheard dialogue line pool:** Write the line pool for NPC-to-NPC conversations that the player can overhear, per D-078. Lines must work at both full clarity (heard normally) and under heavy occlusion (many words dropped). The stochastic word-drop renderer operates per-word — lines must be authored so that partial information is still meaningful and interesting, not just broken. + - **Occlusion-resilient authoring rules:** (1) Front-load key information — the most important word should be in the first third of the sentence. (2) Short declarative sentences — one idea per line turn. (3) No pronoun-first openers (`"She told me..."` → `"Kael told me..."`) — the first word is statistically the most likely to drop; pronouns without antecedent are unresolvable. (4) Each exchange turn must be self-contained — a player who hears only one side of the exchange should still get a complete thought. + - **Volume and tagging:** Minimum 40 exchange pairs (speaker A line + speaker B response = 1 pair). Cover three registers: social (idle chat, personal news), work (shift logistics, job gripes), gossip (third-party knowledge payload — something the player could use). Tag each pair with: `relationship_type` (colleague, friend, hostile, romantic), `topic`, and `knowledge_payload` (a brief description of what the player can infer from hearing this exchange clearly — or partially). + - **Format:** Use the D-035 YAML schema (see #168 for schema file). These are NPC-sourced lines, not player-character lines — `role` = `npc`, `access` and `trust` tags apply normally. Monologue-specific tags (`character`, `trigger`, `prerequisite`) are not used for this pool; replace with `relationship_type` and `knowledge_payload` as custom extensions, or confirm schema handling with Gestalt before writing. + - **Placement:** Output to `content/npc-conversations/overheard.yaml` (new directory, establish convention). + - Mellanie writes lines, Paula validates narrative coherence (Kael/Sera-adjacent exchanges must honour D-034 THE FRIEND arc), Gestalt validates knowledge_payload tags are achievable from KG state. + - Delivery: `content/npc-conversations/overheard.yaml`, minimum 40 exchange pairs, all tagged, all passing the occlusion-resilience checklist. No dependency on server #247 — content is authored ahead of the system that will select and emit it. + +## Dependency Chain + +``` +#253 (monologue architecture) → #120 (line pool files) → #121 (voice guide) +#168 (tagged line pool schema) → parallel with #253 track +#328 (access tier shifts) → parallel, standalone design doc +#536 (NPC-to-NPC overheard line pool) → parallel, standalone, feeds server #247 +``` + +#253 must be started first — it defines the authoring contract that #120 writes to and #121 extends. #120 and #121 can overlap once #253 is drafted (Paula/Mellanie can start voice guide while Mellanie writes lines). #168 and #328 are fully independent and can proceed in parallel with the monologue track. #536 is also fully independent — no blockers, can proceed in parallel with all other copy work. + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section): + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "docs(content): description" --description "body" --base main --head copy +``` diff --git a/docs/sprints/sprint-14/joint.md b/docs/sprints/sprint-14/joint.md new file mode 100644 index 000000000..aacaa6350 --- /dev/null +++ b/docs/sprints/sprint-14/joint.md @@ -0,0 +1,107 @@ +# Sprint 14: Live — Joint / Integration Notes + +**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player. + +**Sprint 14 ID:** 14 +**Status:** planning (activate with `db/connectors/sprint start`) + +--- + +## Pre-Sprint Decisions + +The following decisions are implemented or directly exercised this sprint and must be cross-referenced by the relevant team: + +| Decision | Domain | Implementing ticket(s) | +|----------|--------|----------------------| +| D-024 (10-axis NPC model + combat component) | content | #323 (mood), #324 (trust), #325 (interaction tracking), #101 (routine) | +| D-026 (simulation tiers — ActiveSim scope for behavior) | architecture | #323, #101, #247 | +| D-028 (dialogue — four relational layers) | content | #325 (Layer 2 data), #324 (Layer 3 data), #168 (schema) | +| D-031 (game clock / day phases — routine transitions) | architecture | #101 | +| D-032 (separate monologue pools per character) | content | #120, #121, #122 | +| D-034 (THE FRIEND — Kael Davan / Sera Venn) | content | #318 (visual spec), #253 (monologue architecture) | +| D-035 (converged tag taxonomy) | content | #168 (schema implementation) | +| D-041 (knowledge graph — trust co-gate) | architecture | #324, #325 | +| D-075 (dialogue filtering — trust + confidence co-gate) | content | #324 (trust progression feeds `relationship_to_trust()`) | + +--- + +## Cross-Team Dependencies + +| Dependency | From | To | Risk | +|------------|------|----|------| +| #323 (Mood component) | server | copy #253 (monologue architecture must describe mood influence) | Low — copy doc can reference Mood enum; server implements | +| #325 (InteractionMemory) | server | copy #328 (access tier shifts doc references interaction count as trigger) | Low — spec can be written with intent; server implements | +| #324 (trust progression) | server | copy #121 (voice guide references mood/trust for line selection) | Low — parallel, doc references trust tier conceptually | +| #304 (entity color spec) | visual | client (future sprint — entity_renderer.gd implementation) | Low — spec must be complete before client implements | +| #316 (text display hierarchy) | visual | client #122 (monologue display references hierarchy for positioning) | Low-Medium — Stig should read #316 spec before completing #122 layout | +| #251 (tell visual/behavioral spec) | visual | server (future sprint — tell simulation behavior) | Low — server implements behavior from Araminta's spec next sprint | + +**Coordination protocol:** +- Visual team (#304 first): Start entity color spec before #318 (THE FRIEND visual cross-references it). Signal to joint channel when #304 is drafted. +- Client team (#122): Read the Sprint 13 monologue display spec (`docs/design/monologue-display-spec.md`) before implementing. Also read #316 once Araminta drafts it — consult if positioning conflicts arise. Default to #315 spec if #316 is not ready. +- Server team (#323 first): Mood component is the most downstream dependency for copy and visual. Draft the `Mood` enum in `server/src/npc/mood.rs` early — copy team needs the enum values for #253 authoring contract and #121 voice guide. +- Copy team (#253 first within copy track): Monologue architecture doc gates #120 and partially gates #121. Start it day one. + +--- + +## Sprint Completion Proof + +When Sprint 14 is done, the following is concretely observable: + +1. **NPCs have moods:** Query the ECS world during a gauntlet run — Active-tier NPCs have a `Mood` component with a non-Neutral value after sufficient simulation ticks. The `Mood` transitions when `ToleranceThreshold.current_stress` exceeds threshold, or when day phase changes. + +2. **Trust accumulates:** Talk to the same NPC three times in sequence. After each interaction, `RelationshipGraph.get_relationship(player_id, npc_id).trust` increments. Walk away mid-conversation (D-064 mechanic) — trust decrements. Trust values are deterministic across replays with the same input sequence. + +3. **Routine execution closes the loop:** NPCs in the Active tier move to their routine destination on day-phase transition. After `Morning → Afternoon`, NPCs with an Afternoon `location` in their `DailyRoutine` have an active `PathRequest` issued and eventually reach the target tile. The `ActivityState` component reflects the current routine activity string. + +4. **NPC-to-NPC conversations generate sound:** Two NPCs in the same zone within 3 tiles emit a `SoundEvent` of kind `Voice` on the `ObserverSnapshot.sound_events` field. The client's `sound_indicator_renderer.gd` renders a voice-coloured (#e8c547) pulse at the fog edge in the direction of the conversation. The `AudioManager` plays the murmur asset on the WorldSFX bus (already wired Sprint 13). + +5. **Monologue displays in client:** Launch the game. Walk into a new room. Within 2 ticks, a monologue line appears in the bottom-left HUD area in italic text. Wait idle for ~100 ticks — a `time_idle` line appears. Lines fade after their display duration. No line is lost — if a new line arrives while one is showing, it queues. Character-specific colour is applied. + +6. **Monologue content pools exist:** `content/` directory contains `monologue-smuggler.yaml` and `monologue-detective.yaml` files with at least 8 lines each covering `enter_location` and `time_idle` triggers for at least two locations. All lines validate against the D-035 schema in `content/_schema/dialogue-line.schema.json`. + +7. **Tagged line pool schema is authoritative:** Running `make pre-pr` (CI cross-reference validation, #464) validates all content YAML files against the schema. No validation errors. + +8. **Visual specs are actionable:** `docs/design/entity-color-system.md`, `docs/design/text-display-hierarchy.md`, `docs/design/sound-indicator-visual.md`, `docs/design/the-friend-visual-treatment.md`, `docs/design/environmental-text-standards.md`, and `docs/design/tell-visual-expression.md` all exist with sufficient detail for Stig to implement from the spec alone — hex values, pixel dimensions, z-layer assignments, animation durations. + +9. **Invariant test suite passes:** `cargo test` in `server/` includes `invariants::*` tests that execute against all existing gauntlet rooms without failure. 36 invariants across 4 categories all assert green. + +10. **Access tier shift design exists:** `docs/design/access-tier-shifts.md` documents 2-3 tier transitions per character per social site with specific KG fact triggers and reversibility notes. Ready as design input for #169 (Layer 1, S15). + +--- + +## Test Plan Alignment (D-030) + +Sprint 14 is in the integration phase (D-030 Phase 2+). Test focus: + +- **Server:** New systems (#323 mood, #324 trust, #325 interaction tracking, #101 routine, #103 relationship dynamics, #247 NPC conversations) each require unit tests in their respective modules. Hoshe's test harness pattern: ECS `World` setup, inject components, advance system, assert state change. Determinism required: all random values through `SimRng` (D-010 principle 4). #508 invariant tests run as integration-style tests over gauntlet rooms — these are the first map-agnostic quality gate. +- **Client:** Hoshe's gdUnit4 harness. #122 (monologue display) tests: queue management (queue depth at max, oldest-first eviction?), fade timing, character color correctness, no-overwrite behavior. Verify `current_monologue: None` produces no display (no ghost text from previous tick). +- **Copy:** Content validation is CI-driven (#464 cross-reference check in `make pre-pr`). Line pool YAML files (#120) must pass schema validation. No manual test required for docs (#253, #121, #328, #168). +- **CI:** `make ci` must pass on all branches. `make pre-pr` runs content cross-reference validation including schema check. No new HashMap in simulation crate (clippy ban active). + +--- + +## Decision Coverage Gaps (remaining after Sprint 14) + +The following confirmed decisions still have no implementing tickets. Flag to Team Leader if any block upcoming work: + +- D-031 (time system) — exercised by #101 routine execution and gauntlet shift change room. No standalone ticket needed. +- D-033 (entity color = relationship to player) — #304 this sprint is the spec. Client implementation is a Sprint 15 ticket (not yet created). +- D-036 (Sova Transit District setting) — no world map authoring ticket yet. Relevant to #155 (hand-crafted location authoring, backlog). +- D-040 (wiki taxonomy) — documentation structure, not code. +- D-043–D-046 (art direction decisions) — referenced by #304, #316, #317, #334 this sprint. No standalone implementing tickets. +- D-062 (invisible locked dialogue) — design constraint, implemented by server-side filtering (already in `dialogue.rs`). No new ticket. +- D-070 (confrontation as cognitive vulnerability) — implemented Sprint 13 (audio dip). No further ticket. +- D-074 (audio aesthetic identity) — implemented Sprint 13 (murmur asset). No further ticket. + +--- + +## Notes for Sprint Start + +To activate the sprint once planning is approved: + +```bash +db/connectors/sprint start +``` + +This sets Sprint 14 to `active`. Teams then run `db/connectors/sprint start-work --team ` for their full context dump. diff --git a/docs/sprints/sprint-14/server.md b/docs/sprints/sprint-14/server.md new file mode 100644 index 000000000..02f3b5c04 --- /dev/null +++ b/docs/sprints/sprint-14/server.md @@ -0,0 +1,82 @@ +# Sprint 14: Live — Server Tasks + +**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player. + +**Branch:** `server` +**Agents:** Dudley (simulation dev), Tyre (architect), Hoshe (QA) + +## Carry-over from Sprint 13 + +None. All Sprint 13 server tickets done. + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #323 | NPC mood state machine | — | +| #324 | Trust progression system | — | +| #325 | Interaction tracking component | — | +| #101 | Routine execution system | #87 done, #238 done | +| #103 | Relationship dynamics | — | +| #247 | NPC-to-NPC conversation system | — | +| #508 | Map-agnostic invariant tests (36 invariants) | — | + +Use `db/connectors/ticket show ` for full details. + +## Key Decisions + +- `decisions/architecture.md` — D-024 (10-axis NPC model), D-026 (simulation tiers — ActiveSim scope), D-031 (game clock / day phases — routine transitions), D-041 (knowledge graph — trust feeds Layer 3 filtering), D-054 (tile-based movement), D-075 (dialogue filtering — trust co-gate with KnowledgeConfidence) +- `decisions/content.md` — D-023 (three-tier content model), D-028 (dialogue four-layer model — Layers 2-4 fed by trust/interaction tracking), D-029 (population entanglement ratio — 30/50/20), D-034 (THE FRIEND — trust arc requirements), D-062 (invisible locked options — new trust tier = new options appear silently) +- `decisions/perception.md` — D-016 (internal monologue — mood drives monologue tone), D-018 (three-range sound model — NPC-to-NPC conversations emit Voice events) + +## Notes + +- **#323 — NPC mood state machine:** Add a `Mood` component to `server/src/npc/mod.rs`. Eight moods for v0.1: `Neutral`, `Anxious`, `Frustrated`, `Content`, `Suspicious`, `Warm`, `Hostile`, `Focused` (9th: added Sprint 8, D-035 amendment). Mood changes based on: triangle pressure (high ToleranceThreshold stress → Anxious/Hostile), time of day (Evening + long shift → Frustrated), recent interactions (positive player interaction → Warm). Write a `MoodSystem` in a new `server/src/npc/mood.rs` module. Mood is read by the monologue trigger system (`server/src/simulation/monologue.rs`) and will gate Layer 4 dialogue selection (`server/src/simulation/dialogue.rs`). Blocks #337 (tell state derivation, deferred to S15). Add to `NpcPlugin` in `server/src/npc/mod.rs`. Keep all values `i16`/enum — no floats, per D-010 determinism. + - Delivery: `Mood` component, `update_mood` system, unit tests in `server/src/npc/mood.rs`. + +- **#324 — Trust progression system:** Trust level per player-NPC pair drives D-028 Layer 3 (trust-gated gossip). The `RelationshipGraph` at `server/src/npc/relationships.rs` already holds `trust: i8` on every `RelationshipEdge`. Sprint 14 work: (1) a system that advances trust based on interaction quality — `TalkVerb` completion → small positive increment, confrontation walk-away → negative decrement, repeat visit (from #325 interaction count) → small positive; (2) expose the current trust value on `ObserverSnapshot` or leave it in the KnowledgeGraph (D-041). Trust maps to D-028 TrustTier via `relationship_to_trust()` in `server/src/simulation/dialogue.rs` — D-075 adds KnowledgeConfidence as co-gate to that function. No new ECS component needed — `RelationshipGraph` resource is the store. Write a `update_trust` system in `server/src/npc/relationships.rs`. Blocks #171 (Layer 3 trust-gated gossip, deferred to S15). + - Integration point: `server/src/simulation/dialogue.rs` `process_talk_interaction` must call `relationship_to_trust()` with `KnowledgeConfidence` (D-075 implementation if not already wired). + - Delivery: `update_trust` system, tests verifying trust increments/decrements on interaction events. + +- **#325 — Interaction tracking component:** `InteractionMemory` component per NPC-pair — fields: `interaction_count: u32`, `last_interaction_tick: u64`, `notable_events: Vec`. The `interaction_count` drives D-028 Layer 2 situation activation: `first_meeting` when count == 0, `repeated_visit` when count >= 3. Add to `server/src/npc/mod.rs` or a new `server/src/npc/interaction.rs`. Populated by `process_talk_interaction` in `server/src/simulation/dialogue.rs` each time a Talk verb completes. `notable_events` stores walk-aways (D-064) and confrontations (D-063) — these are already recorded in the KG but `InteractionMemory` provides fast per-pair access without a full KG query. + - Integration point: `server/src/simulation/dialogue.rs` — increment count and stamp tick on each completed Talk. `dialogue.rs` already reads KG and RelationshipGraph; add InteractionMemory to the same query. + - Delivery: `InteractionMemory` component, incremented by dialogue system, used by situation resolver for Layer 2. + +- **#101 — Routine execution system:** The routine data model exists (`DailyRoutine`, `RoutineEntry` in `server/src/npc/mod.rs`). The phase-transition trigger exists (`check_phase_transition` in `server/src/npc/routine.rs`) — it already issues `PathRequest` on phase change. Sprint 14 work: verify the full execution loop closes — `PathRequest` → pathfinder → `path_follow` → entity reaches destination and enters the routine activity state. Add an `activity: String` → `ActivityState` ECS component (or reuse `DailyRoutine.entries[].activity`) so the simulation knows what an NPC is currently doing. This feeds the `DuringActivity` tell trigger (`TellTrigger::DuringActivity` in `server/src/npc/mod.rs`) and the Layer 2 `situation` tag matching. Existing pathfinding: `server/src/simulation/pathfinding.rs`, `server/src/simulation/path_follow.rs`. Gauntlet room `shift_change_*` (Sprint 13 #505) already validates phase-boundary transition — extend it or write new integration tests for full routine loop. + - Delivery: `ActivityState` component (or equivalent) attached/updated as NPCs execute routines; tests verifying NPC reaches routine destination and holds activity state. + +- **#103 — Relationship dynamics:** Relationship values decay and reinforce over time. Existing `RelationshipEdge` in `server/src/npc/relationships.rs` has `trust: i8` and `history: Vec`. Sprint 14 work: (1) passive decay — trust drifts toward 0 at ~1 point per game-day if no recent interaction (controlled by `last_interaction_tick`); (2) interaction reinforcement — talking, helping (future), witnessing positive events strengthens; (3) `RelationshipEvent` is appended on notable interactions. This system runs in the Background tier (D-026) — lightweight, 1 update per game-minute. Add `update_relationship_dynamics` system to `server/src/npc/relationships.rs`, registered in `NpcPlugin`. Blocks #249 (player-action social propagation, deferred to S15). + - Note: Do not confuse with #324 (player-NPC trust). #103 covers NPC-NPC relationship dynamics — the relationship graph that generates the social texture (#249 feeds into). The player-NPC trust arc (THE FRIEND, #324) is a separate ticket. + +- **#247 — NPC-to-NPC conversation system:** NPCs in the Active tier who are in proximity (≤3 tiles) and share a social site occasionally enter NPC-to-NPC conversations. Implementation: (1) a `ConversationSystem` in `server/src/simulation/` that detects eligible NPC pairs (same zone, ActiveSim, not already in player conversation, not sprinting away), initiates a `NpcConversation` state component for the pair, and emits a `SoundEvent` of kind `Voice` at the conversation tile — picked up by `server/src/simulation/sound.rs` and included in `ObserverSnapshot.sound_events`; (2) conversation duration in ticks (configurable constant, ~30-120 ticks = 3-12 game-minutes); (3) conversation terminates when one NPC leaves the zone or duration expires. The client's `sound_indicator_renderer.gd` already handles `Voice` sound events. This is the first source of overheard conversations — the eavesdrop mechanic (D-426 `ListeningFocus`) becomes meaningful when NPCs are actually talking. + - **D-078 addition (amended — occlusion is SERVER-AUTHORITATIVE):** Each `ConversationEvent` on `ObserverSnapshot` must carry: `occluded_line: String` (the NPC dialogue line with dropped words replaced by `...`), `speaker_id: EntityId`, `target_id: EntityId`, `speaker_name: String`, `target_name: String`. The server performs per-word occlusion before emission — the client receives pre-occluded text and renders it verbatim. + - **Per-word occlusion algorithm:** Iterate the dialogue line word by word. For each word, perform an independent Bernoulli trial using a seedable RNG (seeded per tick for deterministic replay, D-010). Per-word drop probability is derived from three inputs at the moment of emission: (a) tile distance from player to the speaking NPC — linear decay from 0.0 drop probability at 0 tiles to 1.0 at the `Voice` sound range boundary; (b) ambient noise level at the player position (already tracked in `ObserverSnapshot.ambient_noise`) — adds up to 0.3 to drop probability; (c) whether the player entity has `ListeningFocus` stance active — subtracts 0.2 from drop probability (clamped to [0.0, 1.0]). Words that fail the trial are replaced with `...` in `occluded_line`. + - When a conversation ends (NPC departs zone or duration expires), emit a `conversation_end` event with the same pair IDs so the client can dismiss the panel. Trigger `witness_interaction` on the player observation pipeline whenever the player receives any conversation event (regardless of occlusion level). + - No dialogue content needed in this ticket — content is sourced from the NPC-to-NPC line pool (#536, copy team, Sprint 14). + - Delivery: `NpcConversation` state component, `run_npc_conversations` system, `Voice` `SoundEvent` emission, `ConversationEvent` struct with `occluded_line` on `ObserverSnapshot`, `conversation_end` event, per-word occlusion function with seedable RNG, tests: `npc_conversation_emits_voice_event_when_in_range`, `npc_conversation_terminates_when_apart`, `occlusion_drops_words_with_distance`, `occlusion_suppressed_by_listening_focus`, `occlusion_deterministic_with_same_seed`. Blocks #535 (client). + +- **#508 — Map-agnostic invariant tests (36 invariants):** 36 invariants across 4 categories from Gestalt's workshop output. Implement as a test suite in `server/src/test_world/` that runs against any valid map (gauntlet rooms). Categories: structural (8 — tile counts, wall connectivity, spawn point validity), perception (5 — LOS symmetry at range, sound range boundaries), population (8 — NPC count limits, tier assignment correctness), simulation (8 — no entity teleports, determinism, pathfinder termination, interaction buffer clear on sprint). Add a `run_invariants(world: &World)` function callable from gauntlet room tests — each room calls it after setup to assert structural invariants hold. Dynamic map system not required — invariants work on static gauntlet maps. + - Note: #509 (fuzzy tests for procedural maps) remains deferred — requires dynamic map generation. + - Delivery: `server/src/test_world/invariants.rs` module with 36 test assertions; each gauntlet room test calls `run_invariants`. + +## Dependency Chain + +``` +#323 (mood) ─────────────────────────────────────→ #337 (tell derivation, S15) +#324 (trust) ────────────────────────────────────→ #171 (Layer 3, S15) +#325 (interaction tracking) → #101 (routine) is parallel + → #103 (relationship dynamics) is parallel + → #247 (NPC conversations) is parallel +#247 (NPC conversations) ────────────────────────→ #535 (client passive panel, blocked) +#508 (invariants) — standalone, parallel with all above +``` + +All six simulation tickets (#323, #324, #325, #101, #103, #247) are independent of each other and can proceed in parallel. #508 is also fully standalone. #535 (client) is blocked on #247 landing the `ConversationEvent` struct on `ObserverSnapshot` — specifically `occluded_line: String` (pre-occluded by server), `speaker_name`, `target_name`, and `conversation_end`. + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section): + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head server +``` diff --git a/docs/sprints/sprint-14/visual.md b/docs/sprints/sprint-14/visual.md new file mode 100644 index 000000000..65fcd43d6 --- /dev/null +++ b/docs/sprints/sprint-14/visual.md @@ -0,0 +1,101 @@ +# Sprint 14: Live — Visual Tasks + +**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player. + +**Branch:** `visual` +**Agents:** Araminta (art direction) + +## Carry-over from Sprint 13 + +None. All Sprint 13 visual tickets done (#315 monologue display spec). + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #304 | Entity Color System Spec — relationship-to-player mapping | #303 done | +| #316 | Text display hierarchy spec — 4 content pipelines | #303 done | +| #317 | Sound indicator visual design — fog-edge pulse | #303 done | +| #318 | THE FRIEND visual treatment spec | #303 done, #297 done, #298 done | +| #334 | Environmental text visual standards | #303 done | +| #251 | Tell visual/behavioral expression | — | + +All six tickets are unblocked. All are spec/design documents — Araminta authors; no code deliverables from the visual branch this sprint. + +Use `db/connectors/ticket show ` for full details. + +## Key Decisions + +- `decisions/content.md` — D-033 (entity color = relationship to player — approved color hex values: unknown=teal #4a9ebb, known/friendly=green #6bc9a6, person-of-interest=amber #e8c547, hostile=red #d45d5d, static objects=grey #8090a8), D-034 (THE FRIEND — phase 1 identical to other friendly NPCs, earned visual detail only), D-043 (art direction — functional warmth), D-044 (visual hierarchy — entity > object > structure), D-045 (environmental neutrality — zero shift with narrative state), D-046 (lighting system — three-reference model), D-047 (two-tier animation system — Tier 1 readable, Tier 2 ambiguous), D-048 (neural insert overlay), D-049 (z-level rendering stack — 8 layers) +- `decisions/perception.md` — D-016 (internal monologue — text rendering), D-017 (perception modes), D-018 (three-range sound model — color codes for sound indicator: neutral #c8d0e0, voices #e8c547, danger #d45d5d), D-035 (tag taxonomy — monologue display spec references character field), D-062 (invisible locked dialogue — no lock icons, no hints) +- `decisions/scope.md` — D-027 (vertical slice success criteria — #3: player names an NPC they felt conflicted about = THE FRIEND) + +## Notes + +- **#304 — Entity Color System Spec:** Araminta formalizes the approved D-033 color model into a full spec document. Output: `docs/design/entity-color-system.md`. Required sections: + - Relationship state → color mapping table (hex values confirmed in D-033 are the canonical source). + - How relationship state is determined at render time: client receives `relationship: RelationshipState` on each `VisibleEntity` in the snapshot (`server/src/bridge/types.rs` `VisibleEntity`). Client maps state to color via a lookup table in `entity_renderer.gd` (`client/scripts/rendering/entity_renderer.gd`). + - Transition behavior: when an entity's relationship state changes (e.g., PersonOfInterest after confrontation), how the color shift renders — instant or tween, duration. + - Static objects: always `#8090a8` regardless of any relationship state. Define what counts as a static object (fixtures, furniture, terminals) vs entity (NPCs, player, carried items). + - Edge cases: player character color (not subject to relationship coloring — different treatment), entities in fog (D-033 color does NOT show through fog — unrecognized fog blobs are grey until cognitive delay resolves), entities at periphery (reduced saturation per D-046 lighting reference model). + - Color blindness note: D-033 colors were selected for functional warmth — include Araminta's assessment of whether the teal/green/amber/red set is distinguishable under deuteranopia and protan deficiency. Flag if adjustment needed. + - This spec is the direct input to the client-side implementation (future sprint — no client ticket this sprint). Must be self-contained enough for Stig to implement from the doc alone. + +- **#316 — Text display hierarchy spec:** How the four content pipelines render distinctly. Output: `docs/design/text-display-hierarchy.md`. Four pipelines: + 1. **Dialogue** (NPC speech + player response options): bottom panel, max 20% height, max ~100 chars per line (D-061). Speaker name styled. Response options styled differently from speech. + 2. **Internal monologue** (player character): left-side or bottom-left, italic, smaller font, character colour from #315 spec (done Sprint 13). + 3. **Observation / overheard** (what the player perceives about NPCs or overhears): distinct treatment from monologue — diegetic information output, not character voice. Consider: greyed label, different font weight, positioned differently. + 4. **Environmental text** (signage, terminals, news tickers in-world): diegetic — appears in the world layer, not the HUD layer. Two-language treatment per D-036 (Concordat Standard + Krenn vernacular). Character limits per format (signage shorter, terminal longer, news ticker scrolling). + - Each pipeline needs: position on screen, font size/weight, colour scheme, max width/height, fade/truncation behaviour. D-049 z-layer assignments for each. + - Reference: `docs/design/monologue-display-spec.md` (#315, done) for monologue pipeline specifics — this spec extends and cross-references it. + +- **#317 — Sound indicator visual design:** Fog-edge pulse indicators for the D-018 three-range sound model. Output: `docs/design/sound-indicator-visual.md`. Design requirements (from ticket and D-018): + - **Purpose:** Complementary to audio, not replacement. Indicates sound presence for players with audio off or in loud environments. + - **Colors:** neutral #c8d0e0 (footsteps, ambient), voices #e8c547 (NPC conversation), danger #d45d5d (alarms, alerts). + - **Position:** Fog edge — where the visible area meets unexplored/deep fog. Not a minimap overlay, not a screen-edge indicator. Appears at the boundary tiles of the player's visible cone. + - **Shape and animation:** Pulse — how many pixels, what frequency, what easing, what opacity range. Must not be distracting during exploration; must be noticeable when specifically a threat. + - **Direction encoding:** Indicates direction the sound comes from — how? Arc segment on fog edge in the direction of source? Size variation? Specify clearly for Stig's implementation. + - **Range differentiation:** Close sounds (≤3 tiles) produce larger/brighter pulses; medium (≤8 tiles) moderate; long (≤20 tiles) subtle. Define the three visual levels. + - **When it does not appear:** When audio is playing and the sound is within the visible area (no need to indicate what you can already see/hear). Rules for suppression. + - The `sound_indicator_renderer.gd` file exists at `client/scripts/rendering/sound_indicator_renderer.gd` — Araminta should note what it currently does vs what the spec calls for. + +- **#318 — THE FRIEND visual treatment spec:** How Kael Davan (smuggler's FRIEND) and Sera Venn (detective's FRIEND) look different from other NPCs through earned visual detail only — no special marking. Output: `docs/design/the-friend-visual-treatment.md`. Per D-034 design principle: Phase 1 identical to other friendly NPCs (green rectangle per D-033). Differentiation accrues through story, not through marking. Required sections: + - **Phase 1 (before player builds relationship):** Identical to any other Known/Friendly NPC. Green rectangle, no distinguishing visual. + - **Phase 2 (after 3+ interactions, trust building):** What subtle visual shift occurs — if any — that is diegetically justified, not metatextual marking. Examples: does the character carry an item that renders (a manifest, a specific color? per D-033 object color rules)? Does their routine placement become more predictable visually? Define what "earned visual detail" means concretely. + - **Phase 3 (contradiction discovered — PersonOfInterest):** Transition to amber #e8c547 per D-033. Monologue firing rate spikes. Spec: when does the color shift? Immediately on confrontation? On player delivery of contradiction knowledge? Tween duration. + - **Animation tier:** THE FRIEND is in Tier 2 animation (ambiguous, privately motivated behaviors per D-047) once the player has seen their contradiction. Before contradiction: Tier 1. Spec the transition point. + - Cross-reference `docs/design/entity-color-system.md` (#304, this sprint) once it is drafted. + +- **#334 — Environmental text visual standards:** How signage, terminals, and news tickers render in-world. Output: `docs/design/environmental-text-standards.md`. Per ticket: + - **Signage:** Short (1-3 words typical), both Concordat Standard and Krenn vernacular. Character limits. Font size relative to tile size (D-066: 1m visual tiles, 2x retina factor). Position: floating above the tile, or rendered on the tile surface. + - **Terminals:** Longer text, readable on interaction (Observe verb). Two-state: ambient (icon/identifier visible from range) and active (text readable when player is adjacent). + - **News tickers:** Scrolling text, ambient, not blocking gameplay. Where on screen — in-world (floating above terminal), not HUD overlay. + - **Bilingual treatment:** Concordat Standard is the colonial lingua franca (neutral, bureaucratic). Krenn vernacular is the local dialect (warm, compact, consonant-heavy per D-036 naming conventions). Which text in which language per context? Formal signage = Concordat Standard. Informal social text (bar menu, worker notices) = Krenn vernacular. Mixed where both audiences are intended. + - **Rendering layer:** Per D-049 z-layer stack — environmental text is object-layer (layer 2-3), not HUD layer (layer 6+). Must not occlude entities. + +- **#251 — Tell visual/behavioral expression:** How NPC behavioral tells manifest in the top-down renderer. Output: `docs/design/tell-visual-expression.md`. Per D-024 tell system (5 categories: nervous, angry, friendly, guarded, routine deviation) and server's `TellSystem` component (`server/src/npc/mod.rs`). In v0.1, tell expression is via **monologue text** (server emits monologue on observe_npc trigger when tell is active), not visual animation — this is confirmed. However, Araminta's task is to define: + - What observable behaviors in the top-down renderer accompany tells — movement hesitation (NPC pauses before entering a room), route changes (NPC takes alternate path), grouping behavior (NPC lingers near another), interaction changes (NPC avoids certain tiles). + - These are **behavioral patterns in the simulation** described visually — the spec tells Dudley what the tell should look like at the tile level, which he implements as movement/pathfinding modifiers. The spec is the design input to server-side tell behavior. + - Per D-047 animation tier: Tier 2 animation is "ambiguous, privately motivated behaviors." Tells are the canonical Tier 2 examples — movement hesitation is a Tier 2 animation. Define which of the 5 tell categories maps to which Tier 2 behaviors. + - Araminta's scope here: the visual description. Dudley implements the simulation behavior. Mellanie writes the monologue that accompanies the tell. + +## Dependency Chain + +``` +#304 (entity color spec) ─────────────────→ #318 (THE FRIEND visual, cross-ref) +#316 (text display hierarchy) ───────────→ standalone +#317 (sound indicator visual) ───────────→ standalone +#318 (THE FRIEND visual) ────────────────→ #251 (tell visual, cross-ref) +#334 (environmental text) ───────────────→ standalone +#251 (tell visual) ──────────────────────→ standalone +``` + +Recommended order: start #304 (entity color) first — #318 cross-references it. #251 and #318 can proceed in parallel once #304 is drafted. The other three (#316, #317, #334) are fully independent and can proceed in any order. + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section): + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "docs(visual): description" --description "body" --base main --head visual +``` From 1b08734838e4ef28dc854ac8bb7c69fe4bc4ca98 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 19:53:02 +0100 Subject: [PATCH 13/14] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5067a2bc5..b435f8a66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion +- Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6) + ## [v0.1.12] — 2026-02-19 ### Added From 4e53a2e97900062015249dd10a9af76ecb0e777d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 18:07:35 +0100 Subject: [PATCH 14/14] chore(meta): release v0.1.13 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 ++ project.yaml | 2 +- server/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b435f8a66..36b8a20f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [v0.1.13] — 2026-02-20 + ### Added - D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion - Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6) diff --git a/project.yaml b/project.yaml index c36d2065d..29ab05f41 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ name: The Settled Reach -version: 0.1.12 +version: 0.1.13 repository: settled-reach codename: commonwealth diff --git a/server/Cargo.toml b/server/Cargo.toml index cc69a9110..9c3645b42 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "settled-reach-server" -version = "0.1.12" +version = "0.1.13" edition = "2021" [dependencies]