diff --git a/server/Cargo.lock b/server/Cargo.lock index ea1dcf2fe..5b4567c66 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.12" +version = "0.1.13" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/src/bin/line_preview.rs b/server/src/bin/line_preview.rs index d6a93955b..6ff6efbbd 100644 --- a/server/src/bin/line_preview.rs +++ b/server/src/bin/line_preview.rs @@ -596,6 +596,8 @@ fn situation_str(s: &Situation) -> &'static str { Situation::Emergency => "emergency", Situation::Routine => "routine", Situation::Observation => "observation", + Situation::FirstMeeting => "first_meeting", + Situation::RepeatedVisit => "repeated_visit", } } @@ -637,5 +639,6 @@ fn mood_str(m: &Mood) -> &'static str { Mood::Conflicted => "conflicted", Mood::Concerned => "concerned", Mood::Relieved => "relieved", + Mood::Focused => "focused", } } diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 8abc99478..b170cdb17 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -300,6 +300,8 @@ mod tests { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], sound_events: vec![], rng_seed: None, } @@ -425,6 +427,8 @@ mod tests { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], sound_events: vec![], rng_seed: None, }; diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 5396fe634..5c784529e 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 = 11; +pub const PROTOCOL_VERSION: u8 = 12; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -31,10 +31,11 @@ pub const PROTOCOL_VERSION: u8 = 11; /// 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). +/// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 11. + /// Protocol version for forward compatibility. Current: 12. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -88,6 +89,15 @@ pub struct ObserverSnapshot { /// Empty when no sounds are in range. #[serde(default)] pub sound_events: Vec, + /// Overheard NPC-to-NPC conversation lines this tick (#247, D-078). + /// Each event carries pre-occluded text — client renders verbatim. + /// Empty when no conversations are overheard. + #[serde(default)] + pub conversation_events: Vec, + /// Conversations that ended this tick (#247, D-078). + /// Client dismisses the passive dialogue panel for these pairs. + #[serde(default)] + pub conversation_ended: Vec, /// RNG seed active at this tick for deterministic replay (#527). /// The WRONG button writes this to seed.txt so replays reproduce observed bugs. /// None when the RNG resource is unavailable (should not occur in practice). diff --git a/server/src/content/line_pool.rs b/server/src/content/line_pool.rs index 2078ae9b6..b6e7a789e 100644 --- a/server/src/content/line_pool.rs +++ b/server/src/content/line_pool.rs @@ -94,6 +94,10 @@ pub enum Situation { Emergency, Routine, Observation, + /// First player-NPC interaction — interaction_count == 0 (#325, D-028 Layer 2). + FirstMeeting, + /// Player has talked to this NPC 3+ times — interaction_count >= 3 (#325, D-028 Layer 2). + RepeatedVisit, } impl FromStr for Situation { @@ -113,6 +117,8 @@ impl FromStr for Situation { "emergency" => Ok(Self::Emergency), "routine" => Ok(Self::Routine), "observation" => Ok(Self::Observation), + "first_meeting" => Ok(Self::FirstMeeting), + "repeated_visit" => Ok(Self::RepeatedVisit), _ => Err(ParseEnumError { kind: "Situation", value: s.to_string(), @@ -157,6 +163,7 @@ impl FromStr for Topic { } /// D-028 Layer 4: Mood tag — influences weighted selection. +/// D-035 amendment (Sprint 8): `Focused` added as 9th variant. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Mood { Fond, @@ -167,6 +174,9 @@ pub enum Mood { Conflicted, Concerned, Relieved, + /// D-035 amendment (Sprint 8): task-focused NPC mood — used at The Terminal + /// and maintenance corridors. Maps from NpcMood::Focused. + Focused, } impl FromStr for Mood { @@ -181,6 +191,7 @@ impl FromStr for Mood { "conflicted" => Ok(Self::Conflicted), "concerned" => Ok(Self::Concerned), "relieved" => Ok(Self::Relieved), + "focused" => Ok(Self::Focused), _ => Err(ParseEnumError { kind: "Mood", value: s.to_string(), diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 190d99051..9e0c5e75e 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -111,6 +111,12 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // Mark NPC as interactable for proximity-based verb detection (#413) entity_commands.insert(Interactable); + // Interaction history — drives Layer 2 situation activation (#325, D-028) + entity_commands.insert(npc::interaction::InteractionMemory::default()); + + // Mood state — drives Layer 4 dialogue selection and monologue tone (#323) + entity_commands.insert(npc::mood::MoodState::default()); + // Axis 1: Want if let Some(want) = &profile.want { if let Some(kind) = parse_want_kind(&want.primary) { diff --git a/server/src/npc/interaction.rs b/server/src/npc/interaction.rs new file mode 100644 index 000000000..aaad2f2b1 --- /dev/null +++ b/server/src/npc/interaction.rs @@ -0,0 +1,179 @@ +//! Interaction tracking component — ticket #325. +//! +//! `InteractionMemory` is a per-NPC component tracking the player's interaction +//! history with that NPC. Drives D-028 Layer 2 situation activation: +//! - `interaction_count == 0` → `Situation::FirstMeeting` +//! - `interaction_count >= 3` → `Situation::RepeatedVisit` +//! +//! Populated by `process_talk_interaction` in `dialogue.rs` each time a talk +//! line is selected. Walk-away and confrontation events appended to +//! `notable_events` for fast per-pair access (complements the KnowledgeGraph). +//! +//! No HashMap. No floats. Deterministic (no random access to notable_events). + +use bevy_ecs::prelude::*; + +/// Notable event kinds recorded per player-NPC interaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InteractionEventKind { + /// Player walked away during active dialogue (D-064). + WalkAway, + /// Player delivered a confrontation (D-063). + Confrontation, +} + +/// A single notable event in an interaction history. +#[derive(Debug, Clone)] +pub struct InteractionEvent { + /// Simulation tick the event occurred. + pub tick: u64, + /// The kind of event. + pub kind: InteractionEventKind, +} + +/// Per-NPC interaction history with the player (#325, D-028 Layer 2). +/// +/// Spawned on every NPC entity. Drives situation derivation for Layer 2 +/// dialogue selection: `first_meeting` (count == 0), `repeated_visit` +/// (count >= 3). `notable_events` stores walk-aways and confrontations for +/// fast lookup without a full KnowledgeGraph query. +#[derive(Component, Debug, Default)] +pub struct InteractionMemory { + /// Total number of completed Talk interactions with the player. + /// Incremented each time a dialogue line is selected in `process_talk_interaction`. + pub interaction_count: u32, + /// Tick of the most recent completed Talk interaction. + /// Used for trust decay baseline (D-028 trust progression, #324). + pub last_interaction_tick: u64, + /// Notable events: walk-aways and confrontations. + /// Bounded by `MAX_NOTABLE_EVENTS` — oldest entries dropped when full. + pub notable_events: std::collections::VecDeque, +} + +/// Maximum number of notable events retained per NPC pair. +pub const MAX_NOTABLE_EVENTS: usize = 16; + +impl InteractionMemory { + /// Record a completed Talk interaction. + /// + /// Increments `interaction_count` and stamps `last_interaction_tick`. + pub fn record_talk(&mut self, tick: u64) { + self.interaction_count = self.interaction_count.saturating_add(1); + self.last_interaction_tick = tick; + } + + /// Append a notable event, dropping the oldest if at capacity. + pub fn push_event(&mut self, event: InteractionEvent) { + if self.notable_events.len() >= MAX_NOTABLE_EVENTS { + self.notable_events.pop_front(); + } + self.notable_events.push_back(event); + } + + /// Returns `true` if this is the first meeting (count == 0). + pub fn is_first_meeting(&self) -> bool { + self.interaction_count == 0 + } + + /// Returns `true` if this qualifies as a repeated visit (count >= 3). + pub fn is_repeated_visit(&self) -> bool { + self.interaction_count >= 3 + } + + /// Count notable events of a given kind. + pub fn count_events(&self, kind: InteractionEventKind) -> usize { + self.notable_events.iter().filter(|e| e.kind == kind).count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_first_meeting() { + let mem = InteractionMemory::default(); + assert!(mem.is_first_meeting()); + assert!(!mem.is_repeated_visit()); + } + + #[test] + fn record_talk_increments_count() { + let mut mem = InteractionMemory::default(); + mem.record_talk(10); + assert_eq!(mem.interaction_count, 1); + assert_eq!(mem.last_interaction_tick, 10); + assert!(!mem.is_first_meeting()); + } + + #[test] + fn repeated_visit_threshold_at_three() { + let mut mem = InteractionMemory::default(); + assert!(!mem.is_repeated_visit()); + mem.record_talk(10); + mem.record_talk(20); + assert!(!mem.is_repeated_visit()); + mem.record_talk(30); + assert!(mem.is_repeated_visit()); + } + + #[test] + fn push_event_appends() { + let mut mem = InteractionMemory::default(); + mem.push_event(InteractionEvent { + tick: 5, + kind: InteractionEventKind::WalkAway, + }); + assert_eq!(mem.notable_events.len(), 1); + assert_eq!(mem.notable_events[0].kind, InteractionEventKind::WalkAway); + } + + #[test] + fn push_event_drops_oldest_when_full() { + let mut mem = InteractionMemory::default(); + for i in 0..MAX_NOTABLE_EVENTS { + mem.push_event(InteractionEvent { + tick: i as u64, + kind: InteractionEventKind::WalkAway, + }); + } + assert_eq!(mem.notable_events.len(), MAX_NOTABLE_EVENTS); + // Pushing one more should drop the oldest (tick=0) + mem.push_event(InteractionEvent { + tick: 99, + kind: InteractionEventKind::Confrontation, + }); + assert_eq!(mem.notable_events.len(), MAX_NOTABLE_EVENTS); + assert_eq!(mem.notable_events[0].tick, 1); // tick=0 dropped + assert_eq!(mem.notable_events.back().unwrap().tick, 99); + } + + #[test] + fn count_events_filters_by_kind() { + let mut mem = InteractionMemory::default(); + mem.push_event(InteractionEvent { + tick: 1, + kind: InteractionEventKind::WalkAway, + }); + mem.push_event(InteractionEvent { + tick: 2, + kind: InteractionEventKind::Confrontation, + }); + mem.push_event(InteractionEvent { + tick: 3, + kind: InteractionEventKind::WalkAway, + }); + assert_eq!(mem.count_events(InteractionEventKind::WalkAway), 2); + assert_eq!(mem.count_events(InteractionEventKind::Confrontation), 1); + } + + #[test] + fn record_talk_saturates_on_overflow() { + let mut mem = InteractionMemory { + interaction_count: u32::MAX, + ..Default::default() + }; + mem.record_talk(1); + assert_eq!(mem.interaction_count, u32::MAX); // saturating_add + } +} diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index abbac1bc6..5d80bff17 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,6 +2,8 @@ // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job +pub mod interaction; +pub mod mood; pub mod relationships; pub mod routine; @@ -21,11 +23,28 @@ pub struct NpcPlugin; impl Plugin for NpcPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .init_resource::() .add_systems( Update, - routine::check_phase_transition - .before(crate::simulation::pathfinding::compute_paths), + ( + routine::check_phase_transition + .before(crate::simulation::pathfinding::compute_paths), + mood::update_mood + .after(routine::check_phase_transition) + .before(crate::simulation::dialogue::process_talk_interaction), + relationships::update_trust + .after(crate::simulation::dialogue::process_talk_interaction) + .after(crate::simulation::dialogue::process_walk_away) + .after(crate::simulation::dialogue::process_confrontation_response) + .before(crate::simulation::time::advance_tick), + relationships::update_relationship_dynamics + .after(relationships::update_trust) + .before(crate::simulation::time::advance_tick), + routine::enter_activity + .after(crate::simulation::movement::validate_movement) + .before(crate::perception::observer::compute_observer_snapshot), + ), ); tracing::debug!("NpcPlugin initialized"); diff --git a/server/src/npc/mood.rs b/server/src/npc/mood.rs new file mode 100644 index 000000000..c2bd26a7a --- /dev/null +++ b/server/src/npc/mood.rs @@ -0,0 +1,739 @@ +//! NPC mood state machine (#323). +//! +//! Implements the 8-state NPC mood FSM (D-024 MoodState axis, D-035 taxonomy). +//! Mood is derived each tick from simulation inputs (stress, time of day, +//! recent interactions) and drives Layer 4 dialogue selection and monologue tone. +//! +//! All state transitions are deterministic — integer arithmetic only (D-010). +//! No floats. No HashMap. +//! +//! ## Integration points +//! - `ToleranceThreshold.current_stress` → primary mood driver +//! - `SimulationTime.day_phase()` → Evening phase adds Frustrated pressure +//! - `InteractionMemory` (Sprint 14, #325) → will set warm_active flag +//! - `CurrentMood` (dialogue.rs) → synced each tick for Layer 4 selection +//! - Monologue trigger system → reads NpcMood for tone selection (D-016, future) +//! - Tell system (#337, deferred to Sprint 15) → reads MoodState + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::content::line_pool::Mood as ContentMood; +use crate::npc::interaction::InteractionMemory; +use crate::npc::{Npc, ToleranceThreshold}; +use crate::simulation::dialogue::CurrentMood; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::{DayPhase, SimulationTime}; + +// --------------------------------------------------------------------------- +// NpcMood enum +// --------------------------------------------------------------------------- + +/// NPC simulation mood — 8-state FSM (D-024, D-035 converged taxonomy). +/// +/// Driven by `ToleranceThreshold` stress, time of day, and interaction events. +/// Maps to `content::line_pool::Mood` for Layer 4 dialogue tag matching. +/// +/// Copy team references this enum when scripting mood conditions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum NpcMood { + /// Default state: no notable stressors, no recent positive events. + #[default] + Neutral, + /// Elevated stress approaching threshold — heightened wariness. + Anxious, + /// Late-shift fatigue or repeated minor irritations. + Frustrated, + /// Low stress, positive recent context — settled and cooperative. + Content, + /// Observing unusual or off-script behavior — targeted wariness. + /// Not reachable from `derive_mood()` — set externally by observation pipeline. + Suspicious, + /// Recent positive player interaction within memory window. + Warm, + /// Stress at or above threshold — confrontational or withdrawn. + Hostile, + /// Actively engaged in a scheduled activity — task-focused. + /// Not reachable from `derive_mood()` — set externally by activity scheduler (#101). + Focused, +} + +// --------------------------------------------------------------------------- +// MoodState component +// --------------------------------------------------------------------------- + +/// Per-NPC mood component — wraps NpcMood for ECS queries. +/// +/// Updated each tick by `update_mood` for Active-tier NPCs. +/// Read by: dialogue Layer 4 (via CurrentMood sync), tell system (#337), +/// monologue tone selection (D-016, future scope). +#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] +pub struct MoodState { + pub mood: NpcMood, + /// Tick when mood last changed — guards against thrashing in tests. + pub changed_tick: u64, +} + +// --------------------------------------------------------------------------- +// Mood mapping: NpcMood → content::line_pool::Mood +// --------------------------------------------------------------------------- + +/// Map NPC simulation mood to the content dialogue tag. +/// +/// Bridges the simulation FSM (NpcMood) with the dialogue line pool system +/// (content::line_pool::Mood). The mapping is intentionally lossy in some +/// directions — multiple simulation moods map to the same content tag when +/// the distinction matters for behavior but not for line selection. +pub fn mood_to_content_mood(mood: NpcMood) -> ContentMood { + match mood { + NpcMood::Neutral => ContentMood::Comfortable, + NpcMood::Anxious => ContentMood::Worried, + NpcMood::Frustrated => ContentMood::Conflicted, + NpcMood::Content => ContentMood::Relieved, + NpcMood::Suspicious => ContentMood::Suspicious, + NpcMood::Warm => ContentMood::Fond, + NpcMood::Hostile => ContentMood::Concerned, + NpcMood::Focused => ContentMood::Focused, + } +} + +// --------------------------------------------------------------------------- +// Mood derivation (pure, testable) +// --------------------------------------------------------------------------- + +/// Stress fraction threshold for Anxious: 60% of tolerance threshold. +/// +/// Uses integer multiplication to avoid division: +/// Anxious when `current_stress * 100 >= threshold * ANXIOUS_STRESS_NUMERATOR` +/// Equivalent to: `current_stress >= threshold * 0.60` +const ANXIOUS_STRESS_NUMERATOR: i16 = 60; + +/// Stress level below which an NPC is considered Content (no notable pressure). +const CONTENT_STRESS_CEILING: i16 = 20; + +/// Minimum stress for Evening → Frustrated (avoids Frustrated at zero stress). +const FRUSTRATED_STRESS_FLOOR: i16 = 10; + +/// Ticks within which a completed Talk interaction keeps the Warm mood active. +/// 300 ticks = 30 game-minutes (D-031: 10 ticks/minute). +pub const WARM_INTERACTION_WINDOW_TICKS: u64 = 300; + +/// Derive NPC mood from simulation inputs. +/// +/// Priority ordering (high to low): +/// 1. Hostile — stress at or above threshold +/// 2. Anxious — stress at 60% of threshold or above +/// 3. Warm — recent positive player interaction +/// 4. Frustrated — Evening phase with non-trivial stress +/// 5. Content — very low stress (< CONTENT_STRESS_CEILING) +/// 6. Neutral — everything else +/// +/// Inputs are all integer or enum — no floats (D-010 determinism). +/// +/// `warm_active`: set by InteractionMemory (#325, Sprint 14) when a positive +/// interaction occurred within the memory window. Placeholder `false` until +/// #325 is wired. +pub fn derive_mood( + current_stress: i16, + threshold: i16, + phase: DayPhase, + warm_active: bool, +) -> NpcMood { + // 1. Hostile: at or above threshold + if current_stress >= threshold { + return NpcMood::Hostile; + } + + // 2. Anxious: above 60% of threshold. + // Guard: skip if threshold == 0 (divide-by-zero equivalent — entity + // has no tolerance and is already Hostile from rule 1). + if threshold > 0 + && (current_stress as i32) * 100 >= (threshold as i32) * (ANXIOUS_STRESS_NUMERATOR as i32) + { + return NpcMood::Anxious; + } + + // 3. Warm: recent positive interaction (priority over Frustrated/Content) + if warm_active { + return NpcMood::Warm; + } + + // 4. Frustrated: Evening phase with non-trivial stress + if phase == DayPhase::Evening && current_stress >= FRUSTRATED_STRESS_FLOOR { + return NpcMood::Frustrated; + } + + // 5. Content: very low stress + if current_stress < CONTENT_STRESS_CEILING { + return NpcMood::Content; + } + + // 6. Neutral: moderate stress, no special conditions + NpcMood::Neutral +} + +// --------------------------------------------------------------------------- +// System: update_mood +// --------------------------------------------------------------------------- + +/// System: update NpcMood and sync CurrentMood for Active-tier NPCs. +/// +/// Reads `ToleranceThreshold` stress and `SimulationTime` day phase to derive +/// the new mood. Updates `MoodState` when mood changes (records changed_tick). +/// Syncs `CurrentMood` (used by dialogue Layer 4) every tick regardless of +/// whether MoodState changed. +/// +/// Scoped to `ActiveSim` — Background-tier NPCs retain their last mood state +/// (D-026). This is intentional: background NPCs simulate passage of time via +/// last-known state, not per-tick derivation. +/// +pub fn update_mood( + time: Res, + mut query: Query< + ( + &mut MoodState, + Option<&mut CurrentMood>, + Option<&ToleranceThreshold>, + Option<&InteractionMemory>, + ), + (With, With), + >, +) { + let phase = time.day_phase(); + let tick = time.tick; + + for (mut mood_state, current_mood_opt, tolerance_opt, interaction_mem_opt) in query.iter_mut() { + let (stress, threshold) = tolerance_opt + .map(|t| (t.current_stress, t.threshold)) + .unwrap_or((0, 50)); // Default: no stress, moderate threshold + + // Warm: recent positive player interaction within memory window (#325) + let warm_active = interaction_mem_opt + .map(|mem| { + mem.interaction_count > 0 + && tick.saturating_sub(mem.last_interaction_tick) + < WARM_INTERACTION_WINDOW_TICKS + }) + .unwrap_or(false); + + let new_mood = derive_mood(stress, threshold, phase, warm_active); + + if mood_state.mood != new_mood { + mood_state.mood = new_mood; + mood_state.changed_tick = tick; + } + + // Sync CurrentMood for dialogue pipeline — always, not just on change. + // CurrentMood drives Layer 4 scoring; it must reflect current simulation + // state even if MoodState itself didn't change this tick. + if let Some(mut current_mood) = current_mood_opt { + current_mood.0 = mood_to_content_mood(new_mood); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::npc::{Npc, ToleranceThreshold}; + use crate::simulation::dialogue::CurrentMood; + use crate::simulation::tier::{ActiveSim, BackgroundSim}; + use crate::simulation::time::{DayPhase, SimulationTime}; + use bevy_ecs::world::World; + + // --- derive_mood unit tests --- + + #[test] + fn mood_hostile_when_stress_equals_threshold() { + assert_eq!( + derive_mood(50, 50, DayPhase::Morning, false), + NpcMood::Hostile + ); + } + + #[test] + fn mood_hostile_when_stress_above_threshold() { + assert_eq!( + derive_mood(80, 50, DayPhase::Morning, false), + NpcMood::Hostile + ); + } + + #[test] + fn mood_anxious_at_60_percent_threshold() { + // 60% of threshold=100 is 60. stress=60 → Anxious. + assert_eq!( + derive_mood(60, 100, DayPhase::Morning, false), + NpcMood::Anxious + ); + } + + #[test] + fn mood_anxious_boundary_above() { + // threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000). + assert_eq!( + derive_mood(30, 50, DayPhase::Morning, false), + NpcMood::Anxious + ); + } + + #[test] + fn mood_not_anxious_just_below_boundary() { + // threshold=50: 60% = 30. stress=29 → not Anxious (29*100=2900 < 3000). + // stress=29 < 20 is false, so → Neutral. + assert_eq!( + derive_mood(29, 50, DayPhase::Morning, false), + NpcMood::Neutral + ); + } + + #[test] + fn mood_warm_when_positive_interaction() { + assert_eq!( + derive_mood(0, 50, DayPhase::Morning, true), + NpcMood::Warm + ); + } + + #[test] + fn mood_frustrated_when_evening_with_stress() { + // stress=25 (not hostile/anxious), Evening phase → Frustrated + assert_eq!( + derive_mood(25, 50, DayPhase::Evening, false), + NpcMood::Frustrated + ); + } + + #[test] + fn mood_not_frustrated_in_morning() { + assert_eq!( + derive_mood(25, 50, DayPhase::Morning, false), + NpcMood::Neutral + ); + } + + #[test] + fn mood_not_frustrated_when_stress_below_floor() { + // stress=5 < FRUSTRATED_STRESS_FLOOR=10 → Content (stress < 20) + assert_eq!( + derive_mood(5, 50, DayPhase::Evening, false), + NpcMood::Content + ); + } + + #[test] + fn mood_content_when_low_stress() { + assert_eq!( + derive_mood(15, 50, DayPhase::Morning, false), + NpcMood::Content + ); + } + + #[test] + fn mood_content_boundary_at_19() { + // stress=19 < CONTENT_STRESS_CEILING=20 → Content + assert_eq!( + derive_mood(19, 50, DayPhase::Morning, false), + NpcMood::Content + ); + } + + #[test] + fn mood_neutral_otherwise() { + // stress=25, not anxious (25*100=2500 < 50*60=3000), not Warm, morning, not Content + // Wait: 25*100=2500, 50*60=3000 → not Anxious. 25 >= 20 → not Content. Morning → not Frustrated. → Neutral + assert_eq!( + derive_mood(25, 50, DayPhase::Morning, false), + NpcMood::Neutral + ); + } + + #[test] + fn mood_priority_hostile_over_anxious_at_threshold() { + // At exactly threshold → Hostile, not Anxious + assert_eq!( + derive_mood(50, 50, DayPhase::Morning, false), + NpcMood::Hostile + ); + } + + #[test] + fn mood_priority_hostile_over_frustrated_evening() { + assert_eq!( + derive_mood(50, 50, DayPhase::Evening, false), + NpcMood::Hostile + ); + } + + #[test] + fn mood_priority_anxious_over_warm() { + // Anxious takes priority over Warm interaction + assert_eq!( + derive_mood(60, 100, DayPhase::Morning, true), + NpcMood::Anxious + ); + } + + #[test] + fn mood_priority_warm_over_frustrated() { + // Warm takes priority over Frustrated (checked before Evening test) + assert_eq!( + derive_mood(25, 50, DayPhase::Evening, true), + NpcMood::Warm + ); + } + + #[test] + fn mood_zero_threshold_is_hostile() { + // stress=0, threshold=0: 0 >= 0 → Hostile + assert_eq!( + derive_mood(0, 0, DayPhase::Morning, false), + NpcMood::Hostile + ); + } + + #[test] + fn mood_content_zero_stress_moderate_threshold() { + // stress=0, threshold=50: not hostile, not anxious (threshold > 0, 0*100=0 < 50*60=3000), + // not warm, not evening, stress < 20 → Content + assert_eq!( + derive_mood(0, 50, DayPhase::Morning, false), + NpcMood::Content + ); + } + + // --- mood_to_content_mood mapping coverage --- + + #[test] + fn mood_mapping_covers_all_variants() { + for mood in [ + NpcMood::Neutral, + NpcMood::Anxious, + NpcMood::Frustrated, + NpcMood::Content, + NpcMood::Suspicious, + NpcMood::Warm, + NpcMood::Hostile, + NpcMood::Focused, + ] { + let _ = mood_to_content_mood(mood); // must not panic + } + } + + #[test] + fn mood_mapping_anxious_is_worried() { + assert_eq!(mood_to_content_mood(NpcMood::Anxious), ContentMood::Worried); + } + + #[test] + fn mood_mapping_warm_is_fond() { + assert_eq!(mood_to_content_mood(NpcMood::Warm), ContentMood::Fond); + } + + #[test] + fn mood_mapping_suspicious_is_suspicious() { + assert_eq!( + mood_to_content_mood(NpcMood::Suspicious), + ContentMood::Suspicious + ); + } + + #[test] + fn mood_mapping_focused_is_focused() { + assert_eq!(mood_to_content_mood(NpcMood::Focused), ContentMood::Focused); + } + + // --- update_mood system integration tests --- + + fn setup_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world + } + + #[test] + fn update_mood_sets_hostile_when_stress_at_threshold() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + MoodState::default(), + CurrentMood::default(), + ToleranceThreshold { + current_stress: 50, + threshold: 50, + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_mood); + schedule.run(&mut world); + + let mood_state = world.get::(npc).unwrap(); + assert_eq!(mood_state.mood, NpcMood::Hostile); + + let current_mood = world.get::(npc).unwrap(); + assert_eq!(current_mood.0, ContentMood::Concerned); + } + + #[test] + fn update_mood_defaults_to_content_without_tolerance() { + let mut world = setup_world(); + + // No ToleranceThreshold → defaults (stress=0, threshold=50) → Content + let npc = world + .spawn(( + Npc, + ActiveSim, + MoodState::default(), + CurrentMood::default(), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_mood); + schedule.run(&mut world); + + let mood_state = world.get::(npc).unwrap(); + assert_eq!(mood_state.mood, NpcMood::Content); + } + + #[test] + fn update_mood_records_changed_tick_on_transition() { + let mut world = setup_world(); + world.resource_mut::().tick = 42; + + let npc = world + .spawn(( + Npc, + ActiveSim, + // Start Warm, will transition to Hostile + MoodState { + mood: NpcMood::Warm, + changed_tick: 0, + }, + CurrentMood::default(), + ToleranceThreshold { + current_stress: 50, + threshold: 50, + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_mood); + schedule.run(&mut world); + + let mood_state = world.get::(npc).unwrap(); + assert_eq!(mood_state.mood, NpcMood::Hostile); + assert_eq!(mood_state.changed_tick, 42); + } + + #[test] + fn update_mood_does_not_update_changed_tick_when_unchanged() { + let mut world = setup_world(); + world.resource_mut::().tick = 42; + + let npc = world + .spawn(( + Npc, + ActiveSim, + // Already Content; no tolerance → will derive Content again + MoodState { + mood: NpcMood::Content, + changed_tick: 5, + }, + CurrentMood::default(), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_mood); + schedule.run(&mut world); + + let mood_state = world.get::(npc).unwrap(); + assert_eq!(mood_state.mood, NpcMood::Content); + assert_eq!(mood_state.changed_tick, 5); // unchanged + } + + #[test] + fn update_mood_skips_background_npcs() { + let mut world = setup_world(); + + // BackgroundSim NPC — must not be updated + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState { + mood: NpcMood::Warm, + changed_tick: 0, + }, + CurrentMood::default(), + ToleranceThreshold { + current_stress: 50, + threshold: 50, // Would → Hostile if processed + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_mood); + schedule.run(&mut world); + + let mood_state = world.get::(npc).unwrap(); + // Must remain Warm — not processed because BackgroundSim, not ActiveSim + assert_eq!(mood_state.mood, NpcMood::Warm); + } + + #[test] + fn update_mood_syncs_current_mood_when_present() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + MoodState::default(), + CurrentMood::default(), // Starts at Comfortable + ToleranceThreshold { + current_stress: 70, + threshold: 100, // → Anxious (70% of 100) + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_mood); + schedule.run(&mut world); + + let current_mood = world.get::(npc).unwrap(); + // Anxious maps to Worried + assert_eq!(current_mood.0, ContentMood::Worried); + } + + #[test] + fn update_mood_works_without_current_mood() { + let mut world = setup_world(); + + // NPC without CurrentMood — system must not panic + let npc = world + .spawn(( + Npc, + ActiveSim, + MoodState::default(), + // No CurrentMood + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_mood); + schedule.run(&mut world); // must not panic + + let mood_state = world.get::(npc).unwrap(); + assert_eq!(mood_state.mood, NpcMood::Content); + } + + // -- Additional QA coverage (Hoshe, Sprint 14) -------------------------- + + #[test] + fn derive_mood_negative_stress_is_content() { + // i16 stress can be negative (e.g. buffs reducing stress below zero). + // Negative stress is well below CONTENT_STRESS_CEILING (20) → Content. + // Note: `current_stress * 100` in the Anxious check can overflow i16 for extreme + // values (stress < -327 or > 327 at threshold=50). Realistic game values stay small. + assert_eq!( + derive_mood(-10, 50, DayPhase::Morning, false), + NpcMood::Content, + "Negative stress not hostile/anxious, morning, stress<20 → Content" + ); + assert_eq!( + derive_mood(-50, 50, DayPhase::Evening, false), + NpcMood::Content, + "Negative stress in Evening: stress < FRUSTRATED_STRESS_FLOOR (10) → Content not Frustrated" + ); + } + + #[test] + fn derive_mood_cannot_return_suspicious_or_focused() { + // Suspicious and Focused are valid NpcMood states but are NOT reachable + // from derive_mood(). They must be set externally by other systems + // (e.g., observation pipeline for Suspicious, activity scheduler for Focused). + // This test documents the invariant: derive_mood never emits these states. + use std::collections::HashSet; + + let phases = [DayPhase::Morning, DayPhase::Afternoon, DayPhase::Evening, DayPhase::Night]; + let stresses: &[i16] = &[-50, -1, 0, 1, 19, 20, 29, 30, 49, 50, 51, 100]; + let thresholds: &[i16] = &[0, 1, 50, 100]; + let warm_flags = [false, true]; + + let mut observed = HashSet::new(); + for &phase in &phases { + for &stress in stresses { + for &threshold in thresholds { + for warm in warm_flags { + let m = derive_mood(stress, threshold, phase, warm); + observed.insert(format!("{:?}", m)); + } + } + } + } + + assert!( + !observed.contains("Suspicious"), + "derive_mood should never return Suspicious — set by observation pipeline" + ); + assert!( + !observed.contains("Focused"), + "derive_mood should never return Focused — set by activity scheduler (#101)" + ); + } + + #[test] + fn update_mood_multiple_npcs_independent() { + let mut world = setup_world(); + + let calm = world + .spawn(( + Npc, + ActiveSim, + MoodState::default(), + CurrentMood::default(), + ToleranceThreshold { + current_stress: 5, + threshold: 50, + }, + )) + .id(); + + let stressed = world + .spawn(( + Npc, + ActiveSim, + MoodState::default(), + CurrentMood::default(), + ToleranceThreshold { + current_stress: 50, + threshold: 50, + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_mood); + schedule.run(&mut world); + + assert_eq!(world.get::(calm).unwrap().mood, NpcMood::Content); + assert_eq!( + world.get::(stressed).unwrap().mood, + NpcMood::Hostile + ); + } +} diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs index 9d4188598..758d4ab9d 100644 --- a/server/src/npc/relationships.rs +++ b/server/src/npc/relationships.rs @@ -1,18 +1,90 @@ -//! Global relationship graph resource (D-024). +//! Global relationship graph resource (D-024) and trust progression (#324). //! //! Tracks how entities feel about each other. Separate from KnowledgeGraph //! (what entities know) — this is what entities feel. //! BTreeMap with tuple key (subject, target) for deterministic iteration //! and efficient prefix queries via range(). +//! +//! Trust progression: interaction events (talk, walk-away, confrontation) +//! adjust the per-edge `trust: i8` value via the `update_trust` system. +//! Trust maps to D-028 TrustTier via `relationship_to_trust()` in dialogue.rs. use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use crate::knowledge::types::StableId; +use crate::knowledge::EntityRegistry; +use crate::simulation::time::SimulationTime; use super::{RelationshipEvent, RelationshipKind}; +// --------------------------------------------------------------------------- +// Trust event types (#324) +// --------------------------------------------------------------------------- + +/// Trust delta for a completed Talk interaction: NPC warms to the player. +pub const TALK_TRUST_DELTA: i8 = 1; + +/// Trust delta when the player walks away mid-dialogue: NPC feels slighted. +pub const WALK_AWAY_TRUST_DELTA: i8 = -1; + +/// Trust delta when the player delivers a confrontation: NPC feels threatened. +pub const CONFRONTATION_TRUST_DELTA: i8 = -2; + +/// Events that modify trust on the RelationshipGraph. +/// +/// Produced by dialogue systems, consumed by `update_trust` each tick. +/// Direction: always (NPC → player), tracking how the NPC feels about +/// the player after an interaction. +#[derive(Debug, Clone)] +pub enum TrustEvent { + /// Player completed a Talk exchange with an NPC. + TalkCompleted { + npc: Entity, + player: Entity, + }, + /// Player walked away during active dialogue (D-064). + WalkAway { + npc: Entity, + player: Entity, + }, + /// Player delivered a confrontation (D-063). + ConfrontationDelivered { + npc: Entity, + player: Entity, + }, +} + +/// Resource: queue of pending trust events. +/// Drained once per tick by the `update_trust` system. +#[derive(Resource, Default)] +pub struct TrustEventQueue { + events: Vec, +} + +impl TrustEventQueue { + /// Push a trust event into the queue. + pub fn push(&mut self, event: TrustEvent) { + self.events.push(event); + } + + /// Drain all pending events. + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + /// Number of pending events. + pub fn len(&self) -> usize { + self.events.len() + } + + /// Whether the queue is empty. + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + /// Edge in the relationship graph. Directed: A's feelings about B. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelationshipEdge { @@ -69,8 +141,8 @@ impl RelationshipGraph { } /// Get all entities who have feelings about a target. - /// Full scan — use for event detection, not per-tick queries. - pub fn who_knows(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { + /// O(N) full scan of all edges — use for event detection, not per-tick queries. + pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { self.edges .iter() .filter(|((_, t), _)| t == target) @@ -98,6 +170,137 @@ impl RelationshipGraph { pub fn is_empty(&self) -> bool { self.edges.is_empty() } + + /// Iterate over all edges mutably (for decay system). + pub fn values_mut(&mut self) -> impl Iterator { + self.edges.values_mut() + } + + /// Get or create an edge between subject and target. + /// + /// If no edge exists, inserts a default Colleague edge with trust 0. + /// Returns a mutable reference for direct field modification. + pub fn ensure_edge( + &mut self, + subject: StableId, + target: StableId, + tick: u64, + ) -> &mut RelationshipEdge { + self.edges + .entry((subject, target)) + .or_insert_with(|| RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 0, + history: vec![], + last_interaction_tick: tick, + }) + } +} + +// --------------------------------------------------------------------------- +// System: update_trust (#324) +// --------------------------------------------------------------------------- + +/// Drain pending trust events and apply deltas to the RelationshipGraph. +/// +/// Each event adjusts the NPC→player trust edge. If no edge exists, +/// one is created with default Colleague kind and trust 0 before applying +/// the delta. Trust is clamped to [-10, +10] per D-010. +/// +/// System ordering: after dialogue systems (which emit the events), +/// before advance_tick. +pub fn update_trust( + mut queue: ResMut, + mut graph: ResMut, + registry: Res, + time: Res, +) { + for event in queue.drain() { + let (npc, player, delta) = match event { + TrustEvent::TalkCompleted { npc, player } => (npc, player, TALK_TRUST_DELTA), + TrustEvent::WalkAway { npc, player } => (npc, player, WALK_AWAY_TRUST_DELTA), + TrustEvent::ConfrontationDelivered { npc, player } => { + (npc, player, CONFRONTATION_TRUST_DELTA) + } + }; + + let Some(npc_sid) = registry.to_stable(npc) else { + tracing::warn!("Trust event for unregistered NPC {:?}", npc); + continue; + }; + let Some(player_sid) = registry.to_stable(player) else { + tracing::warn!("Trust event for unregistered player {:?}", player); + continue; + }; + + let edge = graph.ensure_edge(npc_sid, player_sid, time.tick); + edge.trust = edge.trust.saturating_add(delta).clamp(-10, 10); + edge.last_interaction_tick = time.tick; + + tracing::debug!( + npc = npc_sid.0, + player = player_sid.0, + delta, + new_trust = edge.trust, + "Trust updated" + ); + } +} + +// --------------------------------------------------------------------------- +// System: update_relationship_dynamics (#103) +// --------------------------------------------------------------------------- + +/// Ticks between decay evaluations — 1 game-minute (D-031: 10 ticks/minute). +const DECAY_INTERVAL_TICKS: u64 = 10; + +/// Ticks without interaction before trust decay begins — 1 game-hour +/// (10 ticks/minute × 60 minutes = 600 ticks). +const DECAY_INACTIVITY_THRESHOLD_TICKS: u64 = 600; + +/// Passive trust decay applied per decay interval. +/// Trust drifts toward 0 at 1 point per hour of inactivity. +const DECAY_DELTA: i8 = 1; + +/// Apply passive trust decay to NPC-NPC relationships (#103, D-024). +/// +/// Runs once per game-minute (every 10 ticks). For each relationship edge +/// inactive for more than one game-hour, decays trust 1 point toward 0. +/// Positive trust decreases; negative trust increases; zero trust is stable. +/// +/// This creates the social texture over time: NPCs who haven't interacted +/// recently drift back to neutral, making active relationship maintenance +/// meaningful. Blocks #249 (player-action social propagation, Sprint 15). +/// +/// System ordering: after update_trust, before advance_tick. +pub fn update_relationship_dynamics(time: Res, mut graph: ResMut) { + // Lightweight: evaluate once per game-minute + if time.tick % DECAY_INTERVAL_TICKS != 0 { + return; + } + + for edge in graph.values_mut() { + let ticks_since = time.tick.saturating_sub(edge.last_interaction_tick); + if ticks_since < DECAY_INACTIVITY_THRESHOLD_TICKS { + continue; // Recent interaction — no decay + } + + let old_trust = edge.trust; + edge.trust = match edge.trust.cmp(&0) { + std::cmp::Ordering::Greater => (edge.trust - DECAY_DELTA).max(0), + std::cmp::Ordering::Less => (edge.trust + DECAY_DELTA).min(0), + std::cmp::Ordering::Equal => 0, + }; + + if edge.trust != old_trust { + tracing::trace!( + old_trust, + new_trust = edge.trust, + ticks_inactive = ticks_since, + "NPC relationship trust decayed toward neutral" + ); + } + } } #[cfg(test)] @@ -171,7 +374,7 @@ mod tests { make_edge(RelationshipKind::Family, 8), ); - let knowers = graph.who_knows(&target); + let knowers = graph.who_knows_full_scan(&target); assert_eq!(knowers.len(), 3); } @@ -230,4 +433,339 @@ mod tests { assert_eq!(*keys[1], (StableId(2), StableId(3))); assert_eq!(*keys[2], (StableId(3), StableId(1))); } + + // -- ensure_edge tests (#324) ------------------------------------------- + + #[test] + fn ensure_edge_creates_default_when_missing() { + let mut graph = RelationshipGraph::new(); + let a = StableId(1); + let b = StableId(2); + + let edge = graph.ensure_edge(a, b, 100); + assert_eq!(edge.kind, RelationshipKind::Colleague); + assert_eq!(edge.trust, 0); + assert_eq!(edge.last_interaction_tick, 100); + assert_eq!(graph.edge_count(), 1); + } + + #[test] + fn ensure_edge_returns_existing_edge() { + let mut graph = RelationshipGraph::new(); + let a = StableId(1); + let b = StableId(2); + graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 7)); + + let edge = graph.ensure_edge(a, b, 200); + // Should return existing edge, not overwrite + assert_eq!(edge.kind, RelationshipKind::Friend); + assert_eq!(edge.trust, 7); + assert_eq!(graph.edge_count(), 1); + } + + // -- TrustEventQueue tests (#324) ---------------------------------------- + + #[test] + fn trust_queue_push_and_drain() { + let mut world = bevy_ecs::world::World::new(); + let e1 = world.spawn_empty().id(); + let e2 = world.spawn_empty().id(); + + let mut queue = TrustEventQueue::default(); + assert!(queue.is_empty()); + + queue.push(TrustEvent::TalkCompleted { + npc: e1, + player: e2, + }); + assert_eq!(queue.len(), 1); + + let events = queue.drain(); + assert_eq!(events.len(), 1); + assert!(queue.is_empty()); + } + + // -- update_trust system tests (#324) ------------------------------------ + + fn setup_trust_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + } + + #[test] + fn talk_completed_increments_trust() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, TALK_TRUST_DELTA); + } + + #[test] + fn walk_away_decrements_trust() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + world + .resource_mut::() + .push(TrustEvent::WalkAway { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, WALK_AWAY_TRUST_DELTA); + } + + #[test] + fn confrontation_decrements_trust_more() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + world + .resource_mut::() + .push(TrustEvent::ConfrontationDelivered { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, CONFRONTATION_TRUST_DELTA); + } + + #[test] + fn multiple_talks_accumulate_trust() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + // Push 5 talk events + for _ in 0..5 { + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + } + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, 5); // 5 * TALK_TRUST_DELTA(1) + } + + #[test] + fn trust_clamps_at_positive_ten() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + // Push 15 talk events — should clamp at 10 + for _ in 0..15 { + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + } + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, 10); + } + + #[test] + fn trust_clamps_at_negative_ten() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + // Push 8 confrontation events — 8 * -2 = -16, should clamp at -10 + for _ in 0..8 { + world + .resource_mut::() + .push(TrustEvent::ConfrontationDelivered { npc, player }); + } + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, -10); + } + + #[test] + fn mixed_events_net_correctly() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + // 3 talks (+3) then 1 walk-away (-1) then 1 confrontation (-2) = net 0 + let mut queue = world.resource_mut::(); + queue.push(TrustEvent::TalkCompleted { npc, player }); + queue.push(TrustEvent::TalkCompleted { npc, player }); + queue.push(TrustEvent::TalkCompleted { npc, player }); + queue.push(TrustEvent::WalkAway { npc, player }); + queue.push(TrustEvent::ConfrontationDelivered { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.trust, 0); + } + + #[test] + fn update_trust_updates_last_interaction_tick() { + let mut world = setup_trust_world(); + world.resource_mut::().tick = 42; + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::().register(npc); + world.resource_mut::().register(player); + + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let registry = world.resource::(); + let npc_sid = registry.to_stable(npc).unwrap(); + let player_sid = registry.to_stable(player).unwrap(); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.last_interaction_tick, 42); + } + + #[test] + fn update_trust_preserves_existing_edge_kind() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + let npc_sid = world.resource_mut::().register(npc); + let player_sid = world.resource_mut::().register(player); + + // Pre-populate with a Friend edge at trust 5 + world.resource_mut::().set_relationship( + npc_sid, + player_sid, + make_edge(RelationshipKind::Friend, 5), + ); + + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + let graph = world.resource::(); + let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap(); + assert_eq!(edge.kind, RelationshipKind::Friend); // Kind preserved + assert_eq!(edge.trust, 6); // 5 + 1 + } + + #[test] + fn unregistered_entity_event_is_skipped() { + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + // Only register npc, not player + world.resource_mut::().register(npc); + + world + .resource_mut::() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); // Should not panic + + let graph = world.resource::(); + assert!(graph.is_empty(), "no edge should be created for unregistered entity"); + } } diff --git a/server/src/npc/routine.rs b/server/src/npc/routine.rs index 4a97e8adf..cbd4ace1d 100644 --- a/server/src/npc/routine.rs +++ b/server/src/npc/routine.rs @@ -1,16 +1,43 @@ -//! Daily routine system (#88). +//! Daily routine system (#88, #101). //! //! Detects day-phase transitions (D-031) and issues PathRequests for NPCs -//! whose DailyRoutine has a location for the new phase. +//! whose DailyRoutine has a location for the new phase. Tracks NPC activity +//! state when they arrive at their routine destination (#101). +//! +//! Pipeline: phase transition → PathRequest → pathfinder → path_follow → +//! NPC arrives → enter_activity sets ActivityState. use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; use crate::npc::{DailyRoutine, Npc}; use crate::simulation::movement::TilePosition; -use crate::simulation::pathfinding::PathRequest; +use crate::simulation::pathfinding::{ComputedPath, PathRequest}; use crate::simulation::tier::ActiveSim; use crate::simulation::time::{DayPhase, SimulationTime}; +// --------------------------------------------------------------------------- +// ActivityState component (#101) +// --------------------------------------------------------------------------- + +/// Tracks the activity an NPC is currently performing at their routine location. +/// +/// Set by `enter_activity` when an NPC: +/// 1. Has no active `ComputedPath` or `PathRequest` (finished walking) +/// 2. Is at the location specified by their `DailyRoutine` for the current phase +/// +/// Cleared on phase transitions (replaced with new activity or removed). +/// Feeds `TellTrigger::DuringActivity` and D-028 Layer 2 situation matching. +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct ActivityState { + /// Activity name from `RoutineEntry.activity` (e.g., "Work", "Bar", "Sleep"). + pub activity: String, + /// The day phase this activity belongs to. + pub phase: DayPhase, + /// Tick when the NPC arrived and started this activity. + pub started_tick: u64, +} + /// Resource tracking the previous day phase for transition detection. #[derive(Resource, Debug, Clone)] pub struct PreviousDayPhase { @@ -57,6 +84,9 @@ pub fn check_phase_transition( previous.day = current_day; for (entity, current_pos, routine) in npcs.iter() { + // Clear stale activity on phase transition — will be re-evaluated by enter_activity + commands.entity(entity).remove::(); + if let Some(expected_location) = routine.expected_location(current_phase) { if *current_pos != expected_location { commands.entity(entity).insert(PathRequest { @@ -73,6 +103,76 @@ pub fn check_phase_transition( } } +// --------------------------------------------------------------------------- +// System: enter_activity (#101) +// --------------------------------------------------------------------------- + +/// Set ActivityState when an NPC has arrived at their routine destination. +/// +/// Runs after movement validation. Checks NPCs that: +/// - Have a DailyRoutine and ActiveSim tier +/// - Are NOT currently pathfinding (no ComputedPath or PathRequest) +/// - Are at the location specified for the current day phase +/// - Don't already have the correct ActivityState for the current phase +/// +/// When conditions are met, inserts an ActivityState component. When an NPC +/// has a stale activity from a previous phase and isn't at the new phase's +/// destination, the stale activity is removed. +/// +/// System ordering: after validate_movement, before compute_observer_snapshot. +pub fn enter_activity( + mut commands: Commands, + time: Res, + npcs: Query< + ( + Entity, + &TilePosition, + &DailyRoutine, + Option<&ActivityState>, + ), + ( + With, + With, + Without, + Without, + ), + >, +) { + let current_phase = time.day_phase(); + + for (entity, pos, routine, activity_opt) in npcs.iter() { + // Already performing the correct activity for this phase + if let Some(activity) = activity_opt { + if activity.phase == current_phase { + continue; + } + } + + // Check if at routine destination for current phase + if let Some(entry) = routine.entry_for_phase(current_phase) { + if *pos == entry.location { + commands.entity(entity).insert(ActivityState { + activity: entry.activity.clone(), + phase: current_phase, + started_tick: time.tick, + }); + tracing::trace!( + "Entity {:?}: entered activity '{}' for {:?}", + entity, + entry.activity, + current_phase, + ); + } else { + // Not at destination yet — remove stale activity + commands.entity(entity).remove::(); + } + } else { + // No routine entry for this phase — remove stale activity + commands.entity(entity).remove::(); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -243,4 +343,318 @@ mod tests { let request = world.get::(entity).unwrap(); assert_eq!(request.goal, morning_loc); } + + // -- enter_activity tests (#101) ------------------------------------------ + + #[test] + fn npc_at_routine_destination_gets_activity_state() { + let mut world = setup_world(); + // Time = Afternoon + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, // Already at afternoon destination + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(enter_activity); + schedule.run(&mut world); + world.flush(); + + let state = world.get::(entity).unwrap(); + assert_eq!(state.activity, "Work"); + assert_eq!(state.phase, DayPhase::Afternoon); + assert_eq!(state.started_tick, MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE); + } + + #[test] + fn npc_not_at_destination_no_activity_state() { + let mut world = setup_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let entity = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), // NOT at afternoon location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(10, 10, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(enter_activity); + schedule.run(&mut world); + world.flush(); + + assert!(world.get::(entity).is_none()); + } + + #[test] + fn npc_with_computed_path_excluded() { + let mut world = setup_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, // At destination but still has a path + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ComputedPath { + steps: vec![], + current_index: 0, + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(enter_activity); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(entity).is_none(), + "NPC with ComputedPath should not get ActivityState" + ); + } + + #[test] + fn npc_with_path_request_excluded() { + let mut world = setup_world(); + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + PathRequest { goal: loc }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(enter_activity); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(entity).is_none(), + "NPC with PathRequest should not get ActivityState" + ); + } + + #[test] + fn existing_activity_same_phase_not_overwritten() { + let mut world = setup_world(); + let tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + world.resource_mut::().tick = tick + 100; + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + ActivityState { + activity: "Work".into(), + phase: DayPhase::Afternoon, + started_tick: tick, // Set earlier + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(enter_activity); + schedule.run(&mut world); + world.flush(); + + let state = world.get::(entity).unwrap(); + assert_eq!( + state.started_tick, tick, + "started_tick should be preserved, not updated" + ); + } + + #[test] + fn stale_activity_replaced_on_phase_change() { + let mut world = setup_world(); + // Time = Evening (after Afternoon) + let evening_tick = 2 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + world.resource_mut::().tick = evening_tick; + + let evening_loc = TilePosition::new(20, 20, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + evening_loc, // Already at evening location + DailyRoutine { + entries: vec![ + RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(10, 10, 0), + activity: "Work".into(), + }, + RoutineEntry { + phase: DayPhase::Evening, + location: evening_loc, + activity: "Bar".into(), + }, + ], + description: "Test".into(), + }, + // Stale activity from previous phase + ActivityState { + activity: "Work".into(), + phase: DayPhase::Afternoon, + started_tick: 1000, + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(enter_activity); + schedule.run(&mut world); + world.flush(); + + let state = world.get::(entity).unwrap(); + assert_eq!(state.activity, "Bar"); + assert_eq!(state.phase, DayPhase::Evening); + assert_eq!(state.started_tick, evening_tick); + } + + #[test] + fn no_routine_for_phase_clears_stale_activity() { + let mut world = setup_world(); + // Time = Night + let night_tick = 3 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + world.resource_mut::().tick = night_tick; + + let entity = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(10, 10, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Evening, + location: TilePosition::new(10, 10, 0), + activity: "Bar".into(), + }], + description: "Test".into(), + }, + // Stale activity from Evening, no Night entry + ActivityState { + activity: "Bar".into(), + phase: DayPhase::Evening, + started_tick: 1000, + }, + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(enter_activity); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(entity).is_none(), + "Stale activity should be cleared when no routine entry for current phase" + ); + } + + #[test] + fn phase_transition_clears_activity_state() { + let mut world = setup_world(); + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + ActiveSim, + loc, + DailyRoutine { + entries: vec![ + RoutineEntry { + phase: DayPhase::Morning, + location: loc, + activity: "Work".into(), + }, + RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(20, 20, 0), + activity: "Lunch".into(), + }, + ], + description: "Test".into(), + }, + ActivityState { + activity: "Work".into(), + phase: DayPhase::Morning, + started_tick: 0, + }, + )) + .id(); + + // Trigger phase transition to 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); + world.flush(); + + // ActivityState should be cleared by phase transition + assert!( + world.get::(entity).is_none(), + "Phase transition should clear ActivityState" + ); + // PathRequest should be set for the new phase location + assert!(world.get::(entity).is_some()); + } } diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 089e688e7..2105b6406 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -17,6 +17,7 @@ use crate::perception::cognitive_delay::CognitiveDelay; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::contraband::ScanEventBuffer; +use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::dialogue::DialogueResponseBuffer; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; @@ -78,6 +79,7 @@ pub fn compute_observer_snapshot( Option<&CognitiveDelay>, Option<&mut DialogueResponseBuffer>, Option<&mut ScanEventBuffer>, + Option<&mut ConversationEventBuffer>, ), With, >, @@ -105,6 +107,7 @@ pub fn compute_observer_snapshot( cognitive_delay_opt, mut dialogue_response_opt, mut scan_event_buffer_opt, + mut conversation_buffer_opt, )) = observer_query.single_mut() else { tracing::error!("compute_observer_snapshot: PlayerCharacter query failed"); @@ -191,6 +194,12 @@ pub fn compute_observer_snapshot( .map(|buf| buf.take()) .unwrap_or_default(); + // Drain NPC-to-NPC conversation events (#247, D-078) + let (conversation_events, conversation_ended) = conversation_buffer_opt + .as_mut() + .map(|buf| (buf.take_events(), buf.take_ended())) + .unwrap_or_default(); + // Collect sound events audible to the observer (D-038, #124). // Filter by D-018 range: only events the player can hear based on distance. let sound_events = if let Some(ref queue) = sound_queue { @@ -251,6 +260,8 @@ pub fn compute_observer_snapshot( dialogue_response, blocked_entities, scan_events, + conversation_events, + conversation_ended, sound_events, rng_seed: sim_rng.as_deref().map(|r| r.seed()), }); diff --git a/server/src/simulation/conversation.rs b/server/src/simulation/conversation.rs new file mode 100644 index 000000000..90d7d04d2 --- /dev/null +++ b/server/src/simulation/conversation.rs @@ -0,0 +1,1158 @@ +//! NPC-to-NPC conversation system (#247, D-078). +//! +//! NPCs in the Active tier who are in proximity (≤3 tiles) and share a social +//! site occasionally enter conversations. Conversations emit Voice SoundEvents +//! and produce ConversationEvents with server-authoritative per-word occlusion +//! for the player's ObserverSnapshot. +//! +//! Per-word occlusion algorithm (D-078): +//! For each word, an independent Bernoulli trial determines whether the player +//! hears it. Drop probability = f(distance, ambient_noise, listening_focus). +//! Words that fail are replaced with "..." in `occluded_line`. +//! All arithmetic uses integer percentages (0-100) for D-010 determinism. + +use bevy_ecs::prelude::*; +use rand::Rng; +use serde::{Deserialize, Serialize}; + +use crate::knowledge::registry::StableEntityId; +use crate::knowledge::types::SoundRange; +use crate::knowledge::EntityRegistry; +use crate::npc::Npc; +use crate::simulation::listening::ListeningFocus; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::rng::SimRng; +use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind}; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::SimulationTime; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Maximum tile distance for two NPCs to start a conversation. +const CONVERSATION_PROXIMITY: u32 = 3; + +/// Minimum conversation duration in ticks (3 game-minutes at 10 ticks/min). +const MIN_DURATION_TICKS: u64 = 30; + +/// Maximum conversation duration in ticks (12 game-minutes). +const MAX_DURATION_TICKS: u64 = 120; + +/// Cooldown ticks before an NPC can enter another conversation. +/// 5 game-minutes = 50 ticks. +pub(crate) const CONVERSATION_COOLDOWN_TICKS: u64 = 50; + +/// Chance (0-100) per tick that an eligible NPC pair starts a conversation. +/// Low to prevent every pair chatting every tick. ~2% per tick. +const CONVERSATION_CHANCE_PERCENT: u32 = 2; + +/// Voice sound range boundary in tiles (D-018 Medium = 8). +const VOICE_RANGE_TILES: u32 = 8; + +/// Ticks between conversation lines (~2 game-minutes at 10 ticks/min). +const LINE_INTERVAL_TICKS: u64 = 20; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/// Display name for an NPC, used on the wire for conversation events. +/// Attached during content spawn. +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct NpcName(pub String); + +/// Active NPC-to-NPC conversation session. +/// Attached to the "speaker" NPC (the one who initiated). +/// The "listener" is tracked by entity reference. +#[derive(Component, Debug)] +pub struct NpcConversation { + /// The other NPC in the conversation. + pub partner: Entity, + /// Tick when the conversation started. + pub started_tick: u64, + /// Tick when the conversation will end. + pub end_tick: u64, + /// Ticks since last line was spoken (for pacing). + pub ticks_since_last_line: u64, +} + +/// Cooldown preventing an NPC from entering another conversation too soon. +#[derive(Component, Debug)] +pub struct ConversationCooldown { + pub until_tick: u64, +} + +// --------------------------------------------------------------------------- +// Wire types (cross bridge boundary) +// --------------------------------------------------------------------------- + +/// Conversation event included in ObserverSnapshot when the player overhears +/// an NPC-to-NPC conversation (D-078). +/// +/// The server performs per-word occlusion before emission — the client receives +/// `occluded_line` and renders it verbatim. No stochastic logic on the client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationEvent { + /// The dialogue line with dropped words replaced by "...". + pub occluded_line: String, + /// Wire-format entity ID of the speaking NPC. + pub speaker_id: u64, + /// Wire-format entity ID of the NPC being spoken to. + pub target_id: u64, + /// Display name of the speaker. + pub speaker_name: String, + /// Display name of the target. + pub target_name: String, +} + +/// End-of-conversation event. Client dismisses the passive dialogue panel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationEndEvent { + /// Wire-format entity ID of speaker. + pub speaker_id: u64, + /// Wire-format entity ID of target. + pub target_id: u64, +} + +/// Buffer holding conversation events for snapshot inclusion. +/// Drained once per snapshot via `take()`. +#[derive(Component, Debug, Default)] +pub struct ConversationEventBuffer { + pub events: Vec, + pub ended: Vec, +} + +impl ConversationEventBuffer { + /// Drain and return all conversation events. + pub fn take_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + /// Drain and return all end events. + pub fn take_ended(&mut self) -> Vec { + std::mem::take(&mut self.ended) + } +} + +// --------------------------------------------------------------------------- +// Per-word occlusion (D-078) +// --------------------------------------------------------------------------- + +/// Compute the per-word drop probability as an integer percentage (0-100). +/// +/// Inputs: +/// - `distance`: Manhattan tile distance from player to speaker. +/// - `ambient_noise_pct`: Ambient noise at player position as 0-100 integer. +/// Maps to up to +30 percentage points of drop probability. +/// - `listening_focus`: Whether the player has ListeningFocus active (-20pp). +/// +/// Formula: +/// base = distance * 100 / VOICE_RANGE_TILES (linear 0→100 over range) +/// noise_bonus = ambient_noise_pct * 30 / 100 (up to +30) +/// focus_bonus = if listening_focus { -20 } else { 0 } +/// result = clamp(base + noise_bonus + focus_bonus, 0, 100) +/// +/// All integer arithmetic — no floats (D-010). +pub fn compute_drop_probability( + distance: u32, + ambient_noise_pct: u32, + listening_focus: bool, +) -> u32 { + // Linear distance decay: 0% at distance 0, 100% at VOICE_RANGE_TILES + let base = (distance.min(VOICE_RANGE_TILES) * 100) / VOICE_RANGE_TILES; + + // Ambient noise: scales 0-100 input to 0-30 contribution + let noise_bonus = (ambient_noise_pct.min(100) * 30) / 100; + + // ListeningFocus subtracts 20 + let focus_bonus: i32 = if listening_focus { -20 } else { 0 }; + + let raw = base as i32 + noise_bonus as i32 + focus_bonus; + raw.clamp(0, 100) as u32 +} + +/// Apply per-word occlusion to a dialogue line. +/// +/// Each word undergoes an independent Bernoulli trial: if a random value +/// in [0, 100) is less than `drop_pct`, the word is replaced with "...". +/// Consecutive dropped words collapse into a single "..." per the D-078 spec. +/// +/// Uses SimRng for deterministic replay (D-010). +pub fn occlude_line(line: &str, drop_pct: u32, rng: &mut impl Rng) -> String { + if drop_pct == 0 { + return line.to_string(); + } + if drop_pct >= 100 { + // All words dropped — single ellipsis + if line.split_whitespace().count() > 0 { + return "...".to_string(); + } + return String::new(); + } + + let mut result = Vec::new(); + let mut last_was_dropped = false; + + for word in line.split_whitespace() { + let roll: u32 = rng.random_range(0..100); + if roll < drop_pct { + // Drop this word — collapse consecutive drops + if !last_was_dropped { + result.push("..."); + last_was_dropped = true; + } + } else { + result.push(word); + last_was_dropped = false; + } + } + + result.join(" ") +} + +// --------------------------------------------------------------------------- +// Placeholder line selection +// --------------------------------------------------------------------------- + +/// Placeholder NPC-to-NPC conversation lines. +/// Content sourced from #536 (copy team) — these are development placeholders. +const NPC_CONVERSATION_LINES: &[&str] = &[ + "Heard anything from the night shift?", + "Cargo manifests don't add up again.", + "Keep your head down today.", + "The new arrival's been asking questions.", + "Terminal three has been acting up.", + "Did you see the Commission officer?", + "I need to talk to you about something.", + "Another long shift ahead.", +]; + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// System: initiate new NPC-to-NPC conversations and tick existing ones. +/// +/// Phase 1: Check for eligible NPC pairs (ActiveSim, proximity ≤3, not already +/// in conversation, not on cooldown) and probabilistically start conversations. +/// +/// Phase 2: Tick active conversations — emit Voice SoundEvents and +/// ConversationEvents (with per-word occlusion) for the player's snapshot. +/// Terminate conversations when duration expires or NPCs move apart. +/// +/// System ordering: after validate_movement, before collect_sound_events. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +pub fn run_npc_conversations( + mut commands: Commands, + time: Res, + registry: Res, + mut rng: ResMut, + // All ActiveSim NPCs — candidates for conversation initiation + npc_query: Query< + ( + Entity, + &TilePosition, + Option<&NpcName>, + Option<&NpcConversation>, + Option<&ConversationCooldown>, + Option<&StableEntityId>, + ), + (With, With), + >, + // Player query for occlusion computation + mut player_query: Query< + ( + &TilePosition, + Option<&ListeningFocus>, + &mut ConversationEventBuffer, + ), + With, + >, +) { + // --- Phase 1: Initiate new conversations --- + + // Collect eligible NPCs (not in conversation, not on cooldown). + // Sorted by StableId for deterministic pairing order (D-010). + let mut eligible: Vec<(Entity, TilePosition, u64)> = npc_query + .iter() + .filter(|(_, _, _, conv, cooldown, _)| { + conv.is_none() + && cooldown + .map(|cd| time.tick >= cd.until_tick) + .unwrap_or(true) + }) + .map(|(entity, pos, _, _, _, sid)| { + (entity, *pos, sid.map(|s| s.0 .0).unwrap_or(u64::MAX)) + }) + .collect(); + eligible.sort_by_key(|&(_, _, sid)| sid); + + // Try to pair eligible NPCs within proximity. + // O(N^2) pair scan — acceptable for v0.1 Active-tier counts (30-80 NPCs, D-026). + // If NPC population grows beyond ~200, consider spatial indexing. + // Only one new conversation per tick to avoid spam. + let mut started_this_tick = false; + + for i in 0..eligible.len() { + if started_this_tick { + break; + } + for j in (i + 1)..eligible.len() { + let (entity_a, pos_a, _) = eligible[i]; + let (entity_b, pos_b, _) = eligible[j]; + + let Some(distance) = pos_a.manhattan_distance(&pos_b) else { + continue; // different z-levels + }; + + if distance > CONVERSATION_PROXIMITY { + continue; + } + + // Probabilistic start + let roll: u32 = rng.rng.random_range(0..100); + if roll >= CONVERSATION_CHANCE_PERCENT { + continue; + } + + // Start conversation + let duration = rng.rng.random_range(MIN_DURATION_TICKS..=MAX_DURATION_TICKS); + commands.entity(entity_a).insert(NpcConversation { + partner: entity_b, + started_tick: time.tick, + end_tick: time.tick + duration, + ticks_since_last_line: 0, + }); + + started_this_tick = true; + tracing::debug!( + "NPC conversation started: {:?} ↔ {:?}, duration={} ticks", + entity_a, + entity_b, + duration, + ); + break; + } + } + + // --- Phase 2: Tick active conversations --- + + // Collect active conversations — need mutable access later, so collect first + let active_conversations: Vec<(Entity, NpcConversation, TilePosition, Option)> = + npc_query + .iter() + .filter_map(|(entity, pos, name, conv, _, _)| { + conv.map(|c| { + ( + entity, + NpcConversation { + partner: c.partner, + started_tick: c.started_tick, + end_tick: c.end_tick, + ticks_since_last_line: c.ticks_since_last_line, + }, + *pos, + name.map(|n| n.0.clone()), + ) + }) + }) + .collect(); + + for (speaker_entity, conv, speaker_pos, speaker_name) in &active_conversations { + let speaker_entity = *speaker_entity; + + // Check termination: duration expired + if time.tick >= conv.end_tick { + terminate_conversation( + &mut commands, + ®istry, + &mut player_query, + speaker_entity, + conv.partner, + time.tick, + ); + continue; + } + + // Check termination: partner moved away or no longer ActiveSim + let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _, _)| { + speaker_pos + .manhattan_distance(pos) + .map(|d| d <= CONVERSATION_PROXIMITY) + .unwrap_or(false) + }); + + if partner_ok != Some(true) { + terminate_conversation( + &mut commands, + ®istry, + &mut player_query, + speaker_entity, + conv.partner, + time.tick, + ); + continue; + } + + // Emit Voice SoundEvent at speaker position + if let Some(speaker_sid) = registry.to_stable(speaker_entity) { + let voice_event = SoundEvent::at( + speaker_pos, + SoundEventKind::Voice, + 0.6, + SoundRange::Medium, + Some(speaker_sid.0), + ); + commands + .entity(speaker_entity) + .insert(SoundEventEmitter::new(voice_event)); + } + + // Emit conversation line periodically + if conv.ticks_since_last_line >= LINE_INTERVAL_TICKS || conv.ticks_since_last_line == 0 { + // Select a placeholder line + let line_idx = rng.rng.random_range(0..NPC_CONVERSATION_LINES.len()); + let line_text = NPC_CONVERSATION_LINES[line_idx]; + + // Get partner name + let partner_name = npc_query + .get(conv.partner) + .ok() + .and_then(|(_, _, name, _, _, _)| name.map(|n| n.0.clone())) + .unwrap_or_else(|| "Unknown".to_string()); + + // Compute per-observer occlusion (D-078, D-010 principle 3). + // Iterates all observers — supports future multi-observer scenarios (D-027). + for (player_pos, listening_focus_opt, mut conv_buffer) in + player_query.iter_mut() + { + let distance = speaker_pos + .manhattan_distance(player_pos) + .unwrap_or(u32::MAX); + + // Only emit if within Voice range + if distance <= VOICE_RANGE_TILES { + let listening = listening_focus_opt + .map(|lf| lf.is_eavesdropping()) + .unwrap_or(false); + + // Zone ambient noise — stubbed at 0 until zone-conspicuousness + // (D-071) wires in. Function signature already accepts the value. + let ambient_noise_pct = 0u32; + + let drop_pct = + compute_drop_probability(distance, ambient_noise_pct, listening); + let occluded = occlude_line(line_text, drop_pct, &mut rng.rng); + + let speaker_sid = registry.to_stable(speaker_entity); + let target_sid = registry.to_stable(conv.partner); + + if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { + conv_buffer.events.push(ConversationEvent { + occluded_line: occluded, + speaker_id: s_sid.0, + target_id: t_sid.0, + speaker_name: speaker_name + .clone() + .unwrap_or_else(|| "Unknown".to_string()), + target_name: partner_name.clone(), + }); + } + } + } + + // Reset line timer + commands.entity(speaker_entity).insert(NpcConversation { + partner: conv.partner, + started_tick: conv.started_tick, + end_tick: conv.end_tick, + ticks_since_last_line: 0, + }); + } else { + // Increment line timer + commands.entity(speaker_entity).insert(NpcConversation { + partner: conv.partner, + started_tick: conv.started_tick, + end_tick: conv.end_tick, + ticks_since_last_line: conv.ticks_since_last_line + 1, + }); + } + } +} + +/// Terminate a conversation: remove NpcConversation, apply cooldowns, emit end event. +fn terminate_conversation( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &mut Query< + ( + &TilePosition, + Option<&ListeningFocus>, + &mut ConversationEventBuffer, + ), + With, + >, + speaker: Entity, + partner: Entity, + current_tick: u64, +) { + commands.entity(speaker).remove::(); + + // Apply cooldown to both participants + let until = current_tick + CONVERSATION_COOLDOWN_TICKS; + commands + .entity(speaker) + .insert(ConversationCooldown { until_tick: until }); + commands + .entity(partner) + .insert(ConversationCooldown { until_tick: until }); + + // Emit conversation_end event to all observers (D-010 principle 3). + let speaker_sid = registry.to_stable(speaker); + let target_sid = registry.to_stable(partner); + + if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { + for (_, _, mut conv_buffer) in player_query.iter_mut() { + conv_buffer.ended.push(ConversationEndEvent { + speaker_id: s_sid.0, + target_id: t_sid.0, + }); + } + } + + tracing::debug!( + "NPC conversation ended: {:?} ↔ {:?}", + speaker, + partner, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + // -- Per-word occlusion tests ------------------------------------------- + + #[test] + fn occlusion_drops_words_with_distance() { + // At max range (8 tiles), drop probability is 100% — all words dropped + let drop = compute_drop_probability(VOICE_RANGE_TILES, 0, false); + assert_eq!(drop, 100); + + let mut rng = ChaCha20Rng::seed_from_u64(42); + let result = occlude_line("Hello there friend", drop, &mut rng); + assert_eq!(result, "..."); + } + + #[test] + fn occlusion_preserves_all_words_at_zero_distance() { + let drop = compute_drop_probability(0, 0, false); + assert_eq!(drop, 0); + + let mut rng = ChaCha20Rng::seed_from_u64(42); + let result = occlude_line("Hello there friend", drop, &mut rng); + assert_eq!(result, "Hello there friend"); + } + + #[test] + fn occlusion_suppressed_by_listening_focus() { + // At distance 2 (25% base), no noise, with focus (-20%) → 5% + let without_focus = compute_drop_probability(2, 0, false); + let with_focus = compute_drop_probability(2, 0, true); + + assert!(with_focus < without_focus, "focus should reduce drop probability"); + assert_eq!(without_focus, 25); // 2 * 100 / 8 = 25 + assert_eq!(with_focus, 5); // 25 - 20 = 5 + } + + #[test] + fn occlusion_deterministic_with_same_seed() { + let line = "The cargo manifests don't add up at all"; + let drop_pct = 50; + + let mut rng1 = ChaCha20Rng::seed_from_u64(42); + let mut rng2 = ChaCha20Rng::seed_from_u64(42); + + let result1 = occlude_line(line, drop_pct, &mut rng1); + let result2 = occlude_line(line, drop_pct, &mut rng2); + + assert_eq!(result1, result2, "same seed must produce same occlusion"); + } + + #[test] + fn occlusion_ambient_noise_adds_up_to_30() { + // Max ambient noise (100%) adds 30 percentage points + let no_noise = compute_drop_probability(0, 0, false); + let max_noise = compute_drop_probability(0, 100, false); + + assert_eq!(no_noise, 0); + assert_eq!(max_noise, 30); + } + + #[test] + fn occlusion_clamps_to_zero() { + // Very close + listening focus → should clamp at 0, not go negative + let drop = compute_drop_probability(0, 0, true); + assert_eq!(drop, 0); // 0 - 20 clamped to 0 + } + + #[test] + fn occlusion_clamps_to_100() { + // Far away + max noise → should cap at 100 + let drop = compute_drop_probability(VOICE_RANGE_TILES, 100, false); + assert_eq!(drop, 100); // 100 + 30 clamped to 100 + } + + #[test] + fn occlusion_consecutive_drops_collapse() { + // Ensure consecutive dropped words become a single "..." + let mut rng = ChaCha20Rng::seed_from_u64(0); + // At 100% drop, everything collapses + let result = occlude_line("one two three four five", 100, &mut rng); + assert_eq!(result, "..."); + } + + #[test] + fn occlusion_empty_line() { + let mut rng = ChaCha20Rng::seed_from_u64(42); + let result = occlude_line("", 50, &mut rng); + assert_eq!(result, ""); + } + + #[test] + fn occlusion_linear_distance_scaling() { + // Distance 4 out of 8 = 50% + assert_eq!(compute_drop_probability(4, 0, false), 50); + // Distance 1 out of 8 = 12% (integer division: 1*100/8 = 12) + assert_eq!(compute_drop_probability(1, 0, false), 12); + // Distance 6 out of 8 = 75% + assert_eq!(compute_drop_probability(6, 0, false), 75); + } + + // -- Conversation lifecycle tests (ECS) -------------------------------- + + fn setup_conversation_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + world + } + + #[test] + fn npc_conversation_emits_voice_event_when_in_range() { + let mut world = setup_conversation_world(); + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + NpcName("Bob".to_string()), + )) + .id(); + world.resource_mut::().register(npc_b); + + // Fix the partner reference + world.get_mut::(npc_a).unwrap().partner = npc_b; + + // Spawn player within voice range + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 8, 0), // distance 3 from speaker + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + // Check that a SoundEventEmitter with Voice was attached to the speaker + let emitter = world.get::(npc_a); + assert!( + emitter.is_some(), + "Speaker should have a SoundEventEmitter after conversation tick" + ); + assert_eq!(emitter.unwrap().pending[0].kind, SoundEventKind::Voice); + + // Check that a ConversationEvent was buffered for the player + let buffer = world.get::(player).unwrap(); + assert_eq!( + buffer.events.len(), + 1, + "Player in range should receive a conversation event" + ); + assert_eq!(buffer.events[0].speaker_name, "Alice"); + assert_eq!(buffer.events[0].target_name, "Bob"); + } + + #[test] + fn npc_conversation_terminates_when_apart() { + let mut world = setup_conversation_world(); + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + // Partner is far away (>3 tiles) + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(20, 20, 0), + NpcName("Bob".to_string()), + )) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + // Conversation should be removed + assert!( + world.get::(npc_a).is_none(), + "Conversation should terminate when NPCs are apart" + ); + + // End event should be emitted + let buffer = world.get::(player).unwrap(); + assert_eq!( + buffer.ended.len(), + 1, + "conversation_end event should be emitted" + ); + } + + #[test] + fn conversation_terminates_on_duration_expiry() { + let mut world = setup_conversation_world(); + world.resource_mut::().tick = 101; // Past end_tick + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0))) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(npc_a).is_none(), + "Conversation should terminate when duration expires" + ); + } + + #[test] + fn player_out_of_range_gets_no_event() { + let mut world = setup_conversation_world(); + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + NpcName("Bob".to_string()), + )) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + // Player far away (distance > 8 = VOICE_RANGE_TILES) + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(30, 30, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + let buffer = world.get::(player).unwrap(); + assert!( + buffer.events.is_empty(), + "Player out of voice range should not receive conversation events" + ); + } + + #[test] + fn drop_probability_formula_matches_spec() { + // D-078 spec: linear decay from 0.0 at 0 tiles to 1.0 at range boundary + assert_eq!(compute_drop_probability(0, 0, false), 0); + assert_eq!(compute_drop_probability(VOICE_RANGE_TILES, 0, false), 100); + + // Ambient noise adds up to 0.3 (30pp) + assert_eq!(compute_drop_probability(0, 100, false), 30); + assert_eq!(compute_drop_probability(0, 50, false), 15); + + // ListeningFocus subtracts 0.2 (20pp) + assert_eq!(compute_drop_probability(4, 0, true), 30); // 50 - 20 + } + + // -- Additional QA coverage (Hoshe, Sprint 14) -------------------------- + + #[test] + fn cooldown_applied_to_both_npcs_after_distance_termination() { + // When a conversation terminates (NPCs drift apart), both NPCs must + // receive ConversationCooldown to prevent immediate re-pairing. + let mut world = setup_conversation_world(); + world.resource_mut::().tick = 100; + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 50, + end_tick: 200, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + // Partner far away — conversation should terminate this tick + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(20, 20, 0), + NpcName("Bob".to_string()), + )) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + // Both NPCs must have ConversationCooldown applied + let cooldown_a = world.get::(npc_a); + assert!( + cooldown_a.is_some(), + "Speaker (npc_a) must get ConversationCooldown after termination" + ); + assert_eq!( + cooldown_a.unwrap().until_tick, + 100 + CONVERSATION_COOLDOWN_TICKS, + "Cooldown until_tick must be current_tick + CONVERSATION_COOLDOWN_TICKS" + ); + + let cooldown_b = world.get::(npc_b); + assert!( + cooldown_b.is_some(), + "Partner (npc_b) must get ConversationCooldown after termination" + ); + assert_eq!( + cooldown_b.unwrap().until_tick, + 100 + CONVERSATION_COOLDOWN_TICKS, + "Both NPCs receive the same cooldown duration" + ); + } + + #[test] + fn cooldown_applied_after_duration_expiry() { + // Termination by duration should also apply cooldowns. + let mut world = setup_conversation_world(); + world.resource_mut::().tick = 200; + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, // expired + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0))) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(npc_a).is_some(), + "Speaker must get cooldown after duration expiry" + ); + assert!( + world.get::(npc_b).is_some(), + "Partner must get cooldown after duration expiry" + ); + } + + #[test] + fn npc_on_active_cooldown_cannot_start_conversation() { + // An NPC with ConversationCooldown (until_tick > current_tick) must + // not be eligible for new conversation initiation. + let mut world = setup_conversation_world(); + world.resource_mut::().tick = 50; + + // NPC on cooldown (expires at tick 100, current is 50) + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + ConversationCooldown { until_tick: 100 }, + )); + + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + ConversationCooldown { until_tick: 100 }, + )); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )); + + // Run many ticks — no conversation should ever start because all NPCs are on cooldown + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + + for _ in 0..50 { + schedule.run(&mut world); + world.flush(); + } + + // Verify no NpcConversation was created + let mut conv_query = world.query::<&NpcConversation>(); + assert!( + conv_query.iter(&world).count() == 0, + "NPCs on cooldown must not enter conversations" + ); + } + + #[test] + fn expired_cooldown_allows_conversation_initiation() { + // A cooldown whose until_tick <= current_tick should not block the NPC. + let mut world = setup_conversation_world(); + // Set tick high enough that the cooldown has expired + world.resource_mut::().tick = 200; + + // Both NPCs have cooldowns that expired at tick 100 + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + ConversationCooldown { until_tick: 100 }, // expired at 200 + )); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + ConversationCooldown { until_tick: 100 }, // expired at 200 + )); + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )); + + // With 2% chance per tick, over 300 ticks a conversation is extremely likely. + // Use a fresh world per attempt but share the schedule. + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + + // Run until we see a conversation or hit max attempts + let mut found = false; + for _ in 0..300 { + schedule.run(&mut world); + world.flush(); + + let mut conv_query = world.query::<&NpcConversation>(); + if conv_query.iter(&world).count() > 0 { + found = true; + break; + } + } + + assert!( + found, + "Expired cooldown should allow conversation initiation (2% per tick, 300 attempts)" + ); + } + + #[test] + fn buffer_take_events_drains_and_returns_events() { + let mut buffer = ConversationEventBuffer::default(); + buffer.events.push(ConversationEvent { + occluded_line: "Hello".to_string(), + speaker_id: 1, + target_id: 2, + speaker_name: "Alice".to_string(), + target_name: "Bob".to_string(), + }); + buffer.events.push(ConversationEvent { + occluded_line: "World".to_string(), + speaker_id: 1, + target_id: 2, + speaker_name: "Alice".to_string(), + target_name: "Bob".to_string(), + }); + + let taken = buffer.take_events(); + assert_eq!(taken.len(), 2, "take_events should return all events"); + assert!(buffer.events.is_empty(), "Buffer should be empty after take_events"); + + // Second call returns empty + let taken2 = buffer.take_events(); + assert!(taken2.is_empty(), "Second take_events call should return empty vec"); + } + + #[test] + fn buffer_take_ended_drains_and_returns_end_events() { + let mut buffer = ConversationEventBuffer::default(); + buffer.ended.push(ConversationEndEvent { + speaker_id: 10, + target_id: 20, + }); + + let taken = buffer.take_ended(); + assert_eq!(taken.len(), 1, "take_ended should return all end events"); + assert!(buffer.ended.is_empty(), "ended buffer should be empty after take_ended"); + + // Second call returns empty + assert!(buffer.take_ended().is_empty()); + } +} diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index ff3603fe1..d84264bbe 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -26,6 +26,8 @@ use crate::content::line_pool::{ }; use crate::content::LinePoolIndexResource; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; +use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory}; +use crate::npc::relationships::{TrustEvent, TrustEventQueue}; use crate::simulation::monologue::{MonologueBuffer, MonologueState}; use crate::simulation::movement::PlayerCharacter; use crate::simulation::rng::SimRng; @@ -346,6 +348,7 @@ pub fn process_talk_interaction( registry: Res, mut rng: ResMut, mut event_queue: ResMut, + mut trust_queue: ResMut, mut player_query: Query< ( Entity, @@ -357,7 +360,7 @@ pub fn process_talk_interaction( ), With, >, - npc_query: Query<(&DialogueProfile, Option<&CurrentMood>)>, + mut npc_query: Query<(&DialogueProfile, Option<&CurrentMood>, Option<&mut InteractionMemory>)>, ) { let Some(line_pool) = line_pool else { return; @@ -377,8 +380,8 @@ pub fn process_talk_interaction( let target = talk_request.target; - // Look up NPC dialogue profile and mood - let Ok((profile, mood_opt)) = npc_query.get(target) else { + // Look up NPC dialogue profile, mood, and interaction history (#325) + let Ok((profile, mood_opt, mut interaction_mem_opt)) = npc_query.get_mut(target) else { tracing::debug!( "Talk target {:?} has no DialogueProfile — cannot select dialogue", target @@ -397,7 +400,16 @@ pub fn process_talk_interaction( let access_tiers = available_access_tiers(relationship); // Layer 2: Derive active situations from game state - let situations = derive_situations(time.day_phase(), relationship); + let mut situations = derive_situations(time.day_phase(), relationship); + + // Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028) + if let Some(ref mem) = interaction_mem_opt { + if mem.is_first_meeting() { + situations.push(Situation::FirstMeeting); + } else if mem.is_repeated_visit() { + situations.push(Situation::RepeatedVisit); + } + } // Layer 3: Trust tier from relationship + confidence (D-075) // Default to Suspects for unknown NPCs — no KG entry means no basis for @@ -498,6 +510,17 @@ pub fn process_talk_interaction( started_tick: time.tick, }); + // Trust progression (#324): successful talk warms the NPC + trust_queue.push(TrustEvent::TalkCompleted { + npc: target, + player: player_entity, + }); + + // Interaction tracking (#325): record completed talk + if let Some(ref mut mem) = interaction_mem_opt { + mem.record_talk(time.tick); + } + tracing::debug!( "Dialogue selected: id={}, speaker={}, location={}, role={}", line.id, @@ -537,8 +560,10 @@ pub fn process_talk_interaction( pub fn process_walk_away( mut commands: Commands, mut event_queue: ResMut, + mut trust_queue: ResMut, time: Res, query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With>, + mut npc_mem_query: Query>, ) { let Ok((player_entity, active_dialogue_opt, _walk_away)) = query.single() else { return; @@ -570,6 +595,20 @@ pub fn process_walk_away( }, }); + // Trust progression (#324): walk-away reduces NPC trust + trust_queue.push(TrustEvent::WalkAway { + npc: target, + player: player_entity, + }); + + // Interaction tracking (#325): record notable walk-away event + if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) { + mem.push_event(InteractionEvent { + tick: time.tick, + kind: InteractionEventKind::WalkAway, + }); + } + tracing::debug!( "Walk-away during {:?} dialogue at tick {} (started tick {}): \ target {:?} → Tier2 animation + routine deviation", @@ -616,6 +655,7 @@ pub fn process_confrontation_response( time: Res, registry: Res, mut rng: ResMut, + mut trust_queue: ResMut, mut query: Query< ( Entity, @@ -626,6 +666,7 @@ pub fn process_confrontation_response( ), With, >, + mut npc_mem_query: Query>, ) { let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) = query.single_mut() @@ -670,10 +711,24 @@ pub fn process_confrontation_response( }); monologue_state.last_fired_tick = time.tick; + // Trust progression (#324): confrontation significantly reduces NPC trust + trust_queue.push(TrustEvent::ConfrontationDelivered { + npc: target, + player: player_entity, + }); + + // Interaction tracking (#325): record confrontation notable event + if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) { + mem.push_event(InteractionEvent { + tick: time.tick, + kind: InteractionEventKind::Confrontation, + }); + } + tracing::info!( tick = time.tick, monologue_id = id, - "Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike" + "Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike + trust penalty" ); // Clean up marker @@ -1039,6 +1094,7 @@ mod tests { world.insert_resource(SimRng::new(42)); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world } @@ -1708,6 +1764,7 @@ mod tests { world.insert_resource(SimulationTime::default()); world.init_resource::(); world.insert_resource(SimRng::new(42)); + world.init_resource::(); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) @@ -1758,6 +1815,7 @@ mod tests { world.insert_resource(SimulationTime::default()); world.init_resource::(); world.insert_resource(SimRng::new(42)); + world.init_resource::(); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) @@ -1797,6 +1855,7 @@ mod tests { world.insert_resource(SimulationTime::default()); world.init_resource::(); world.insert_resource(SimRng::new(42)); + world.init_resource::(); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) @@ -1836,6 +1895,7 @@ mod tests { world.insert_resource(SimulationTime::default()); world.init_resource::(); world.insert_resource(SimRng::new(42)); + world.init_resource::(); let npc = world .spawn((crate::npc::Npc, TilePosition::new(5, 6, 0))) diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index af7e66716..ac305bad4 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -5,6 +5,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; pub mod contraband; +pub mod conversation; pub mod dialogue; pub mod input; pub mod interaction; @@ -48,6 +49,9 @@ impl Plugin for SimulationPlugin { contraband::check_contraband_scan .after(movement::validate_movement) .before(crate::perception::observer::compute_observer_snapshot), + conversation::run_npc_conversations + .after(movement::validate_movement) + .before(sound::collect_sound_events), sound::collect_sound_events .after(movement::validate_movement) .before(crate::perception::observer::compute_observer_snapshot), diff --git a/server/src/test_world/invariants.rs b/server/src/test_world/invariants.rs new file mode 100644 index 000000000..a32532f9a --- /dev/null +++ b/server/src/test_world/invariants.rs @@ -0,0 +1,1088 @@ +//! Map-agnostic invariant tests — ticket #508. +//! +//! 36 invariants across 4 categories that must hold for ANY valid gauntlet map: +//! - Structural (8): tile counts, wall connectivity, spawn point validity +//! - Perception (5): LOS symmetry, sound range boundaries +//! - Population (8): NPC count limits, tier assignment correctness +//! - Simulation (8): no entity at blocked tile, determinism, pathfinder +//! termination, interaction buffer cleared on sprint +//! +//! The `run_invariants(world: &mut World)` function covers the 29 structural, +//! perception, population, and simulation invariants checkable via pure +//! world queries. Additional `#[test]` functions exercise the 7 invariants +//! that require system execution (pathfinding, movement, interaction systems). +//! +//! Spec references: D-010 (determinism), D-018 (sound ranges), D-026 (tiers), +//! D-030 (testability), D-035 (symmetric shadowcasting), D-054 (tile movement), +//! D-055 (sprint suppresses interaction buffer). + +#[cfg(feature = "gauntlet")] +use bevy_ecs::world::World; + +#[cfg(feature = "gauntlet")] +use crate::knowledge::registry::{EntityRegistry, StableEntityId}; +#[cfg(feature = "gauntlet")] +use crate::knowledge::types::SoundRange; +#[cfg(feature = "gauntlet")] +use crate::npc::{Npc, Want}; +#[cfg(feature = "gauntlet")] +use crate::perception::shadowcast::compute_fov; +#[cfg(feature = "gauntlet")] +use crate::simulation::interaction::NearbyInteractionBuffer; +#[cfg(feature = "gauntlet")] +use crate::simulation::monologue::MonologueBuffer; +#[cfg(feature = "gauntlet")] +use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +#[cfg(feature = "gauntlet")] +use crate::simulation::pathfinding::{ComputedPath, PathBlocked}; +#[cfg(feature = "gauntlet")] +use crate::simulation::sound::SoundEvent; +#[cfg(feature = "gauntlet")] +use crate::npc::interaction::InteractionMemory; +#[cfg(feature = "gauntlet")] +use crate::npc::mood::MoodState; +#[cfg(feature = "gauntlet")] +use crate::npc::routine::ActivityState; +#[cfg(feature = "gauntlet")] +use crate::simulation::tier::{ActiveSim, BackgroundSim, StateSaved}; + +#[cfg(feature = "gauntlet")] +use super::constants::{CROWD_PLAZA, EXPECTED_ENTITY_COUNT, ROOMS}; +#[cfg(feature = "gauntlet")] +use super::{MAP_HEIGHT, MAP_WIDTH}; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Assert that all 29 world-query invariants hold for the given world. +/// +/// Call this after gauntlet room setup to confirm structural, perception, +/// population, and simulation properties are satisfied. +/// +/// Panics with a descriptive message if any invariant is violated. +#[cfg(feature = "gauntlet")] +pub fn run_invariants(world: &mut World) { + // --- Structural (8) --- + inv_s1_walkable_tiles_exist(world); + inv_s2_walkable_count_in_map_bounds(world); + inv_s3_player_spawn_walkable(world); + inv_s4_room_spawns_walkable(world); + inv_s5_room_observers_walkable(world); + inv_s6_rooms_non_overlapping(world); + inv_s7_room_interiors_have_walkable_tiles(world); + inv_s8_hub_center_walkable(world); + + // --- Perception (5) --- + inv_p1_close_sound_range_spec(world); + inv_p2_medium_sound_range_spec(world); + inv_p3_long_sound_range_spec(world); + inv_p4_los_symmetry_at_hub(world); + inv_p5_los_range_bounded(world); + + // --- Population (8) --- + inv_pop1_active_npc_count_within_limit(world); + inv_pop2_all_npcs_have_tile_position(world); + inv_pop3_all_npcs_have_exactly_one_tier(world); + inv_pop4_no_same_layer_collision(world); + inv_pop5_entity_count_matches_expected(world); + inv_pop6_all_npcs_have_want(world); + inv_pop7_crowd_plaza_npc_count(world); + inv_pop8_stable_ids_unique(world); + + // --- Simulation (8) --- + inv_sim1_no_entity_at_blocked_tile(world); + inv_sim2_computed_path_steps_walkable(world); + inv_sim3_no_path_and_blocked_combined(world); + inv_sim4_player_entity_present(world); + inv_sim5_npc_positions_in_bounds(world); + inv_sim6_registry_has_entities(world); + inv_sim7_player_has_monologue_buffer(world); + inv_sim8_player_has_interaction_buffer(world); + + // --- Sprint 14 component invariants --- + inv_s14_active_npcs_have_mood_state(world); + inv_s14_npcs_have_interaction_memory(world); + inv_s14_no_activity_state_with_path_request(world); + inv_pop3b_no_double_tagged_tiers(world); +} + +// =========================================================================== +// Category: Structural (8 invariants) +// =========================================================================== + +/// S1: The WalkabilityMap resource exists and has at least one walkable tile. +/// A fully-blocked map would make the game unplayable and indicates a setup error. +#[cfg(feature = "gauntlet")] +fn inv_s1_walkable_tiles_exist(world: &mut World) { + let wm = world + .get_resource::() + .expect("S1: WalkabilityMap resource must exist"); + let mut found = false; + 'outer: for y in 0..MAP_HEIGHT { + for x in 0..MAP_WIDTH { + if wm.can_move_to(&TilePosition::new(x, y, 0)) { + found = true; + break 'outer; + } + } + } + assert!( + found, + "S1: WalkabilityMap must have at least one walkable tile in the gauntlet bounds" + ); +} + +/// S2: Walkable tile count is within the possible map area. +/// Counts walkable tiles and asserts they do not exceed the map bounding box. +#[cfg(feature = "gauntlet")] +fn inv_s2_walkable_count_in_map_bounds(world: &mut World) { + let wm = world + .get_resource::() + .expect("S2: WalkabilityMap resource must exist"); + let max_tiles = (MAP_WIDTH as usize) * (MAP_HEIGHT as usize); + let mut count = 0usize; + for y in 0..MAP_HEIGHT { + for x in 0..MAP_WIDTH { + if wm.can_move_to(&TilePosition::new(x, y, 0)) { + count += 1; + } + } + } + assert!( + count <= max_tiles, + "S2: walkable tile count ({}) must not exceed map area ({}x{}={})", + count, + MAP_WIDTH, + MAP_HEIGHT, + max_tiles + ); +} + +/// S3: The player entity's spawn tile is walkable. +/// A player spawned into a wall tile cannot move and blocks all room tests. +#[cfg(feature = "gauntlet")] +fn inv_s3_player_spawn_walkable(world: &mut World) { + let player_pos = { + let mut q = world.query_filtered::<&TilePosition, bevy_ecs::prelude::With>(); + *q.single(world).expect("S3: exactly one PlayerCharacter must exist") + }; + let wm = world + .get_resource::() + .expect("S3: WalkabilityMap resource must exist"); + assert!( + wm.can_move_to(&player_pos), + "S3: player spawn {:?} must be on a walkable tile", + player_pos + ); +} + +/// S4: Every gauntlet room's designated spawn position is walkable. +/// NPCs and the player teleported to room spawns must have valid starting tiles. +#[cfg(feature = "gauntlet")] +fn inv_s4_room_spawns_walkable(world: &mut World) { + let wm = world + .get_resource::() + .expect("S4: WalkabilityMap resource must exist"); + for room in ROOMS { + assert!( + wm.can_move_to(&room.spawn), + "S4: spawn {:?} in room '{}' must be walkable", + room.spawn, + room.name + ); + } +} + +/// S5: Every gauntlet room's designated observer (golden-file) position is walkable. +/// Observer positions used for snapshot tests must be valid standing tiles. +#[cfg(feature = "gauntlet")] +fn inv_s5_room_observers_walkable(world: &mut World) { + let wm = world + .get_resource::() + .expect("S5: WalkabilityMap resource must exist"); + for room in ROOMS { + assert!( + wm.can_move_to(&room.observer), + "S5: observer {:?} in room '{}' must be walkable", + room.observer, + room.name + ); + } +} + +/// S6: No two gauntlet room bounding boxes overlap. +/// Overlapping rooms create ambiguous zone assignment and StableId conflicts. +#[cfg(feature = "gauntlet")] +fn inv_s6_rooms_non_overlapping(_world: &mut World) { + for (i, a) in ROOMS.iter().enumerate() { + for (j, b) in ROOMS.iter().enumerate() { + if i >= j { + continue; + } + let overlap_x = + a.origin.x < b.origin.x + b.size.0 && a.origin.x + a.size.0 > b.origin.x; + let overlap_y = + a.origin.y < b.origin.y + b.size.1 && a.origin.y + a.size.1 > b.origin.y; + assert!( + !(overlap_x && overlap_y), + "S6: rooms '{}' and '{}' have overlapping bounding boxes", + a.name, + b.name + ); + } + } +} + +/// S7: Each room has at least one walkable interior tile. +/// A room whose interior is fully blocked has no usable space for entities. +/// Interior is [origin+2, origin+size-2) in both axes (2-tile walls on each side). +#[cfg(feature = "gauntlet")] +fn inv_s7_room_interiors_have_walkable_tiles(world: &mut World) { + let wm = world + .get_resource::() + .expect("S7: WalkabilityMap resource must exist"); + for room in ROOMS { + let x_start = room.origin.x + 2; + let x_end = room.origin.x + room.size.0 - 2; + let y_start = room.origin.y + 2; + let y_end = room.origin.y + room.size.1 - 2; + let mut found = false; + 'outer: for y in y_start..y_end { + for x in x_start..x_end { + if wm.can_move_to(&TilePosition::new(x, y, room.origin.z)) { + found = true; + break 'outer; + } + } + } + assert!( + found, + "S7: room '{}' (interior x={}..{}, y={}..{}) must have at least one walkable tile", + room.name, + x_start, + x_end, + y_start, + y_end + ); + } +} + +/// S8: The Hub center tile (50, 58) is walkable. +/// This is the global player starting position; a blocked hub center breaks navigation. +#[cfg(feature = "gauntlet")] +fn inv_s8_hub_center_walkable(world: &mut World) { + let wm = world + .get_resource::() + .expect("S8: WalkabilityMap resource must exist"); + let hub_center = TilePosition::new(50, 58, 0); + assert!( + wm.can_move_to(&hub_center), + "S8: Hub center {:?} must be walkable — it is the global player starting position", + hub_center + ); +} + +// =========================================================================== +// Category: Perception (5 invariants) +// =========================================================================== + +/// P1: Close sound range ceiling matches D-018 spec (3 tiles). +/// Tests that the constant hasn't drifted from the design decision. +#[cfg(feature = "gauntlet")] +fn inv_p1_close_sound_range_spec(_world: &mut World) { + assert_eq!( + SoundEvent::max_range_tiles(SoundRange::Close), + 3, + "P1: Close sound range must be 3 tiles per D-018" + ); +} + +/// P2: Medium sound range ceiling matches D-018 spec (8 tiles). +#[cfg(feature = "gauntlet")] +fn inv_p2_medium_sound_range_spec(_world: &mut World) { + assert_eq!( + SoundEvent::max_range_tiles(SoundRange::Medium), + 8, + "P2: Medium sound range must be 8 tiles per D-018" + ); +} + +/// P3: Long sound range ceiling matches D-018 spec (20 tiles). +#[cfg(feature = "gauntlet")] +fn inv_p3_long_sound_range_spec(_world: &mut World) { + assert_eq!( + SoundEvent::max_range_tiles(SoundRange::Long), + 20, + "P3: Long sound range must be 20 tiles per D-018" + ); +} + +/// P4: Symmetric shadowcasting satisfies LOS symmetry (D-035). +/// +/// If tile A can see tile B, then B must also be able to see A. +/// Tested at the Hub: observer at (50, 58), target at (54, 58) — clear line. +#[cfg(feature = "gauntlet")] +fn inv_p4_los_symmetry_at_hub(world: &mut World) { + let wm = world + .get_resource::() + .expect("P4: WalkabilityMap resource must exist"); + + let is_opaque = |x: i32, y: i32| !wm.can_move_to(&TilePosition::new(x, y, 0)); + + // Two open-floor positions in the Hub: (50, 58) and (54, 58) + let (ax, ay, bx, by) = (50i32, 58i32, 54i32, 58i32); + let range = 12; + + let fov_a = compute_fov(&is_opaque, ax, ay, range, 0); + let fov_b = compute_fov(&is_opaque, bx, by, range, 0); + + // A sees B → B must see A (symmetric shadowcasting guarantee, D-035) + if fov_a.is_visible(bx, by) { + assert!( + fov_b.is_visible(ax, ay), + "P4: LOS symmetry violated — ({},{}) sees ({},{}) but the reverse is false (D-035)", + ax, + ay, + bx, + by + ); + } + // B sees A → A must see B + if fov_b.is_visible(ax, ay) { + assert!( + fov_a.is_visible(bx, by), + "P4: LOS symmetry violated — ({},{}) sees ({},{}) but the reverse is false (D-035)", + bx, + by, + ax, + ay + ); + } +} + +/// P5: Tiles beyond the FOV range are not visible from the origin (Chebyshev). +/// +/// Computes FOV with range=6 from the Hub center and asserts that a tile at +/// Chebyshev distance > 6 does not appear in the visible set. +#[cfg(feature = "gauntlet")] +fn inv_p5_los_range_bounded(world: &mut World) { + let wm = world + .get_resource::() + .expect("P5: WalkabilityMap resource must exist"); + + let is_opaque = |x: i32, y: i32| !wm.can_move_to(&TilePosition::new(x, y, 0)); + let range = 6; + let (ox, oy) = (50i32, 58i32); + + let fov = compute_fov(&is_opaque, ox, oy, range, 0); + + // Tile at Chebyshev distance = range+2 must not be visible. + let (out_x, out_y) = (ox + range + 2, oy); + assert!( + !fov.is_visible(out_x, out_y), + "P5: tile ({},{}) at Chebyshev distance {} from origin ({},{}) must not be visible \ + with range={}", + out_x, + out_y, + range + 2, + ox, + oy, + range + ); +} + +// =========================================================================== +// Category: Population (8 invariants) +// =========================================================================== + +/// Pop1: Active-tier NPC count does not exceed the D-026 tick-budget limit (80). +#[cfg(feature = "gauntlet")] +fn inv_pop1_active_npc_count_within_limit(world: &mut World) { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + let count = q.iter(world).count(); + assert!( + count <= 80, + "Pop1: active NPC count ({}) must not exceed 80 (D-026 ActiveSim limit)", + count + ); +} + +/// Pop2: Every Npc entity has a TilePosition component. +/// A positionless NPC is invisible to perception and pathfinding systems. +#[cfg(feature = "gauntlet")] +fn inv_pop2_all_npcs_have_tile_position(world: &mut World) { + let total = { + let mut q = world.query_filtered::<(), bevy_ecs::prelude::With>(); + q.iter(world).count() + }; + let with_pos = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + assert_eq!( + with_pos, + total, + "Pop2: all {} NPCs must have TilePosition; only {} do", + total, + with_pos + ); +} + +/// Pop3: Every NPC has exactly one simulation tier marker (ActiveSim, BackgroundSim, StateSaved). +/// Missing or double-tagged NPCs cause behaviour-system duplicates or silent omissions. +#[cfg(feature = "gauntlet")] +fn inv_pop3_all_npcs_have_exactly_one_tier(world: &mut World) { + let total = { + let mut q = world.query_filtered::<(), bevy_ecs::prelude::With>(); + q.iter(world).count() + }; + let active = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + let bg = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + let ss = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + let tier_sum = active + bg + ss; + assert_eq!( + tier_sum, + total, + "Pop3: each of {} NPCs must have exactly one tier marker; \ + found {} Active + {} Background + {} StateSaved = {} (should equal {})", + total, + active, + bg, + ss, + tier_sum, + total + ); +} + +/// Pop4: No two entities share the same tile in the same posture layer (D-054). +/// Same-layer collision on spawn is a world-setup error. +#[cfg(feature = "gauntlet")] +fn inv_pop4_no_same_layer_collision(world: &mut World) { + use std::collections::BTreeSet; + use crate::simulation::movement::TilePresence; + use bevy_ecs::prelude::Entity; + + let mut q = world.query::<(Entity, &TilePosition, Option<&TilePresence>)>(); + let occupied: Vec<(TilePosition, TilePresence, Entity)> = q + .iter(world) + .map(|(e, pos, pres)| (*pos, pres.copied().unwrap_or_default(), e)) + .collect(); + + let mut seen: BTreeSet<(TilePosition, TilePresence)> = BTreeSet::new(); + for (pos, layer, entity) in occupied { + assert!( + seen.insert((pos, layer)), + "Pop4: entity {:?} shares tile {:?} + layer {:?} with another entity — \ + same-layer collision violates D-054", + entity, + pos, + layer + ); + } +} + +/// Pop5: EntityRegistry entity count matches EXPECTED_ENTITY_COUNT from StableId ranges. +/// Drift indicates a room was added or removed without updating constants. +#[cfg(feature = "gauntlet")] +fn inv_pop5_entity_count_matches_expected(world: &mut World) { + let registry = world + .get_resource::() + .expect("Pop5: EntityRegistry resource must exist"); + assert_eq!( + registry.len(), + EXPECTED_ENTITY_COUNT, + "Pop5: EntityRegistry has {} entities; expected {} from StableId range constants", + registry.len(), + EXPECTED_ENTITY_COUNT + ); +} + +/// Pop6: Every Npc entity has a Want component (D-024 axis 1 is mandatory). +/// An NPC without a Want cannot be scored by the storyteller system. +#[cfg(feature = "gauntlet")] +fn inv_pop6_all_npcs_have_want(world: &mut World) { + let total = { + let mut q = world.query_filtered::<(), bevy_ecs::prelude::With>(); + q.iter(world).count() + }; + let with_want = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + assert_eq!( + with_want, + total, + "Pop6: all {} NPCs must have a Want component; only {} do (D-024 axis 1)", + total, + with_want + ); +} + +/// Pop7: Crowd Plaza contains exactly 15 NPCs (D-026 density stress test — 5×3 grid). +/// The grid layout is fixed; deviations indicate the room builder changed. +#[cfg(feature = "gauntlet")] +fn inv_pop7_crowd_plaza_npc_count(world: &mut World) { + let cp = &CROWD_PLAZA; + let mut q = world.query_filtered::<&TilePosition, bevy_ecs::prelude::With>(); + let count = q + .iter(world) + .filter(|pos| { + pos.x >= cp.origin.x + && pos.x < cp.origin.x + cp.size.0 + && pos.y >= cp.origin.y + && pos.y < cp.origin.y + cp.size.1 + && pos.z == cp.origin.z + }) + .count(); + assert_eq!( + count, + 15, + "Pop7: Crowd Plaza must contain exactly 15 NPCs; found {}", + count + ); +} + +/// Pop8: All StableEntityId components attached to entities are unique. +/// Duplicate StableIds corrupt knowledge-graph references and snapshot output. +#[cfg(feature = "gauntlet")] +fn inv_pop8_stable_ids_unique(world: &mut World) { + use std::collections::BTreeSet; + use crate::knowledge::types::StableId; + use bevy_ecs::prelude::Entity; + + let mut q = world.query::<(Entity, &StableEntityId)>(); + let ids: Vec<(StableId, Entity)> = q + .iter(world) + .map(|(e, sid)| (sid.0, e)) + .collect(); + + let mut seen: BTreeSet = BTreeSet::new(); + for (id, entity) in ids { + assert!( + seen.insert(id), + "Pop8: duplicate StableId {:?} found on entity {:?} — StableIds must be unique", + id, + entity + ); + } +} + +// =========================================================================== +// Category: Simulation (8 invariants) +// =========================================================================== + +/// Sim1: No movable entity (NPC or PlayerCharacter) is positioned on a non-walkable tile. +/// +/// Fixtures such as reset plates and signs may be placed in wall tiles intentionally +/// (they are interacted with from adjacent tiles, not stood upon). This invariant +/// targets entities that are expected to move: NPCs and the player character. +#[cfg(feature = "gauntlet")] +fn inv_sim1_no_entity_at_blocked_tile(world: &mut World) { + use bevy_ecs::prelude::{Entity, Or, With}; + + // Only check movable entities: Npc + PlayerCharacter. Fixtures/signs/reset plates + // may legitimately sit in wall tiles (interactable from range, not traversed). + let positions: Vec<(Entity, TilePosition)> = { + let mut q = world.query_filtered::<(Entity, &TilePosition), Or<(With, With)>>(); + q.iter(world).map(|(e, p)| (e, *p)).collect() + }; + let wm = world + .get_resource::() + .expect("Sim1: WalkabilityMap resource must exist"); + + for (entity, pos) in positions { + assert!( + wm.can_move_to(&pos), + "Sim1: movable entity {:?} is at blocked tile {:?} — NPCs and players must \ + spawn on walkable tiles", + entity, + pos + ); + } +} + +/// Sim2: All steps in any ComputedPath are walkable tiles. +/// A path through a wall tile would cause the entity to move through geometry. +#[cfg(feature = "gauntlet")] +fn inv_sim2_computed_path_steps_walkable(world: &mut World) { + use bevy_ecs::prelude::Entity; + + let paths: Vec<(Entity, Vec)> = { + let mut q = world.query::<(Entity, &ComputedPath)>(); + q.iter(world) + .map(|(e, p)| (e, p.steps.clone())) + .collect() + }; + let wm = world + .get_resource::() + .expect("Sim2: WalkabilityMap resource must exist"); + + for (entity, steps) in paths { + for (step_idx, step) in steps.iter().enumerate() { + assert!( + wm.can_move_to(step), + "Sim2: entity {:?} ComputedPath step {} at {:?} is not walkable", + entity, + step_idx, + step + ); + } + } +} + +/// Sim3: PathBlocked and ComputedPath are mutually exclusive on any entity. +/// Having both indicates the pathfinder produced contradictory output. +#[cfg(feature = "gauntlet")] +fn inv_sim3_no_path_and_blocked_combined(world: &mut World) { + use bevy_ecs::prelude::{Entity, With}; + + let path_entities: Vec = { + let mut q = world.query_filtered::>(); + q.iter(world).collect() + }; + for entity in path_entities { + assert!( + world.get::(entity).is_none(), + "Sim3: entity {:?} has both ComputedPath and PathBlocked — mutually exclusive", + entity + ); + } +} + +/// Sim4: Exactly one PlayerCharacter entity exists. +#[cfg(feature = "gauntlet")] +fn inv_sim4_player_entity_present(world: &mut World) { + use bevy_ecs::prelude::With; + + let mut q = world.query_filtered::<(), With>(); + let count = q.iter(world).count(); + assert_eq!( + count, + 1, + "Sim4: exactly one PlayerCharacter must exist; found {}", + count + ); +} + +/// Sim5: All NPC TilePositions are within the gauntlet map bounds. +/// Out-of-bounds positions indicate a misconfigured room builder. +#[cfg(feature = "gauntlet")] +fn inv_sim5_npc_positions_in_bounds(world: &mut World) { + use bevy_ecs::prelude::{Entity, With}; + + let npc_positions: Vec<(Entity, TilePosition)> = { + let mut q = world.query_filtered::<(Entity, &TilePosition), With>(); + q.iter(world).map(|(e, p)| (e, *p)).collect() + }; + for (entity, pos) in npc_positions { + assert!( + pos.x >= 0 && pos.x < MAP_WIDTH && pos.y >= 0 && pos.y < MAP_HEIGHT && pos.z >= 0, + "Sim5: NPC {:?} has out-of-bounds position {:?} (map bounds: {}x{} z>=0)", + entity, + pos, + MAP_WIDTH, + MAP_HEIGHT + ); + } +} + +/// Sim6: EntityRegistry contains at least one entity (the player). +/// An empty registry indicates gauntlet setup failed entirely. +#[cfg(feature = "gauntlet")] +fn inv_sim6_registry_has_entities(world: &mut World) { + let registry = world + .get_resource::() + .expect("Sim6: EntityRegistry resource must exist"); + assert!( + registry.len() > 0, + "Sim6: EntityRegistry must contain at least one entity (the player)" + ); +} + +/// Sim7: The PlayerCharacter entity has a MonologueBuffer component. +/// Missing MonologueBuffer silently drops all monologue events for the player. +#[cfg(feature = "gauntlet")] +fn inv_sim7_player_has_monologue_buffer(world: &mut World) { + use bevy_ecs::prelude::{Entity, With}; + + let player: Entity = { + let mut q = world.query_filtered::>(); + q.single(world).expect("Sim7: PlayerCharacter must exist") + }; + assert!( + world.get::(player).is_some(), + "Sim7: PlayerCharacter must have MonologueBuffer component" + ); +} + +/// Sim8: The PlayerCharacter entity has a NearbyInteractionBuffer component. +/// Missing NearbyInteractionBuffer causes all interaction verbs to be silently dropped. +#[cfg(feature = "gauntlet")] +fn inv_sim8_player_has_interaction_buffer(world: &mut World) { + use bevy_ecs::prelude::{Entity, With}; + + let player: Entity = { + let mut q = world.query_filtered::>(); + q.single(world).expect("Sim8: PlayerCharacter must exist") + }; + assert!( + world.get::(player).is_some(), + "Sim8: PlayerCharacter must have NearbyInteractionBuffer component" + ); +} + +// =========================================================================== +// Sprint 14 component invariants +// =========================================================================== + +/// S14-1: Every Active-tier NPC has a MoodState component. +/// Active NPCs without MoodState are invisible to the mood-driven dialogue pipeline. +#[cfg(feature = "gauntlet")] +fn inv_s14_active_npcs_have_mood_state(world: &mut World) { + let active = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + let with_mood = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + assert_eq!( + with_mood, + active, + "S14-1: all {} Active NPCs must have MoodState; only {} do", + active, + with_mood + ); +} + +/// S14-2: Every NPC has an InteractionMemory component. +/// NPCs without InteractionMemory cannot drive Layer 2 situation activation. +#[cfg(feature = "gauntlet")] +fn inv_s14_npcs_have_interaction_memory(world: &mut World) { + let total = { + let mut q = world.query_filtered::<(), bevy_ecs::prelude::With>(); + q.iter(world).count() + }; + let with_mem = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + assert_eq!( + with_mem, + total, + "S14-2: all {} NPCs must have InteractionMemory; only {} do", + total, + with_mem + ); +} + +/// S14-3: No entity has both ActivityState and PathRequest simultaneously. +/// ActivityState means "at destination, performing activity". PathRequest means +/// "needs to move somewhere". Both at once is contradictory. +#[cfg(feature = "gauntlet")] +fn inv_s14_no_activity_state_with_path_request(world: &mut World) { + use bevy_ecs::prelude::{Entity, With}; + use crate::simulation::pathfinding::PathRequest; + + let with_activity: Vec = { + let mut q = world.query_filtered::>(); + q.iter(world).collect() + }; + for entity in with_activity { + assert!( + world.get::(entity).is_none(), + "S14-3: entity {:?} has both ActivityState and PathRequest — mutually exclusive", + entity + ); + } +} + +/// Pop3b: No NPC is double-tagged with multiple tier markers. +/// Pop3 catches missing tiers via sum check, but not double-tagged entities +/// (e.g. both ActiveSim + BackgroundSim would still sum correctly). +#[cfg(feature = "gauntlet")] +fn inv_pop3b_no_double_tagged_tiers(world: &mut World) { + use bevy_ecs::prelude::{Entity, With}; + + let active_and_bg: Vec = { + let mut q = world.query_filtered::, With, With)>(); + q.iter(world).collect() + }; + assert!( + active_and_bg.is_empty(), + "Pop3b: {} NPC(s) are double-tagged with both ActiveSim and BackgroundSim", + active_and_bg.len() + ); + + let active_and_ss: Vec = { + let mut q = world.query_filtered::, With, With)>(); + q.iter(world).collect() + }; + assert!( + active_and_ss.is_empty(), + "Pop3b: {} NPC(s) are double-tagged with both ActiveSim and StateSaved", + active_and_ss.len() + ); + + let bg_and_ss: Vec = { + let mut q = world.query_filtered::, With, With)>(); + q.iter(world).collect() + }; + assert!( + bg_and_ss.is_empty(), + "Pop3b: {} NPC(s) are double-tagged with both BackgroundSim and StateSaved", + bg_and_ss.len() + ); +} + +// =========================================================================== +// Additional system-execution tests (7 more invariants, via #[test]) +// Tests 30-36 exercise behaviour that requires running ECS systems. +// =========================================================================== + +#[cfg(all(test, feature = "gauntlet"))] +mod system_tests { + use super::*; + use bevy_ecs::prelude::*; + use bevy_ecs::schedule::Schedule; + + use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind}; + use crate::simulation::interaction::{compute_nearby_interactions, Interactable, NearbyInteractionBuffer}; + use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; + use crate::simulation::pathfinding::{compute_paths, ComputedPath, PathBlocked, PathRequest}; + use crate::simulation::stance::Stance; + use crate::simulation::tier::ActiveSim; + use crate::bridge::types::MovementStance; + use crate::knowledge::registry::EntityRegistry; + + // ----------------------------------------------------------------------- + // Invariant 30-31: Pathfinder terminates on adjacent tile + // ----------------------------------------------------------------------- + + /// Sim-30/31: compute_paths consumes PathRequest and produces ComputedPath + /// for an adjacent walkable tile (happy path — pathfinder must terminate). + #[test] + fn pathfinder_terminates_on_adjacent_tile() { + let mut world = World::new(); + world.insert_resource(WalkabilityMap::new(10, 10, 1)); + + let entity = world + .spawn(( + TilePosition::new(5, 5, 0), + PathRequest { + goal: TilePosition::new(5, 4, 0), + }, + )) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(compute_paths); + schedule.run(&mut world); + + // Invariant 30: PathRequest consumed (pathfinder must not hang) + assert!( + world.get::(entity).is_none(), + "Sim-30: PathRequest must be consumed after compute_paths runs" + ); + // Invariant 31: ComputedPath produced with correct step + let path = world + .get::(entity) + .expect("Sim-31: ComputedPath must be produced for a reachable goal"); + assert_eq!( + path.steps, + vec![TilePosition::new(5, 4, 0)], + "Sim-31: adjacent-tile path must contain exactly one step" + ); + } + + // ----------------------------------------------------------------------- + // Invariant 32-33: Pathfinder returns PathBlocked when no route exists + // ----------------------------------------------------------------------- + + /// Sim-32/33: compute_paths on an unreachable goal emits PathBlocked and + /// still consumes the PathRequest (pathfinder terminates on blocked goals). + #[test] + fn pathfinder_blocked_when_no_route() { + let mut world = World::new(); + let mut map = WalkabilityMap::new(10, 10, 1); + let goal = TilePosition::new(5, 3, 0); + for neighbor in goal.cardinal_neighbors() { + map.set_walkable(&neighbor, false); + } + world.insert_resource(map); + + let entity = world + .spawn((TilePosition::new(5, 5, 0), PathRequest { goal })) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(compute_paths); + schedule.run(&mut world); + + // Invariant 32: PathRequest consumed even when no route exists + assert!( + world.get::(entity).is_none(), + "Sim-32: PathRequest must be consumed even when no route exists" + ); + // Invariant 33: PathBlocked emitted + assert!( + world.get::(entity).is_some(), + "Sim-33: PathBlocked must be inserted when goal is unreachable" + ); + } + + // ----------------------------------------------------------------------- + // Invariant 34: Determinism — same setup produces same NPC positions + // ----------------------------------------------------------------------- + + /// Sim-34: Two independent worlds with identical state run through compute_paths + /// and produce identical NPC TilePositions (D-010 determinism). + #[test] + fn simulation_determinism_same_positions() { + fn build_world() -> World { + let mut world = World::new(); + world.insert_resource(WalkabilityMap::new(20, 20, 1)); + + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(4, 5, 0), + PathRequest { + goal: TilePosition::new(8, 5, 0), + }, + Want { + primary: WantKind::Safety, + intensity: 5, + description: "det-test".to_string(), + }, + Contentment { level: 0 }, + ToleranceThreshold { + current_stress: 0, + threshold: 40, + }, + )); + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(10, 10, 0), + PathRequest { + goal: TilePosition::new(2, 2, 0), + }, + Want { + primary: WantKind::Safety, + intensity: 3, + description: "det-test-2".to_string(), + }, + Contentment { level: 0 }, + ToleranceThreshold { + current_stress: 0, + threshold: 40, + }, + )); + + let mut schedule = Schedule::default(); + schedule.add_systems(compute_paths); + schedule.run(&mut world); + world + } + + fn npc_positions(world: &mut World) -> Vec { + let mut q = world.query_filtered::<&TilePosition, With>(); + let mut positions: Vec = q.iter(world).copied().collect(); + positions.sort(); + positions + } + + let mut world1 = build_world(); + let mut world2 = build_world(); + + assert_eq!( + npc_positions(&mut world1), + npc_positions(&mut world2), + "Sim-34: identical world setup must produce identical NPC positions (D-010 determinism)" + ); + } + + // ----------------------------------------------------------------------- + // Invariant 35-36: Sprint suppresses interaction buffer (D-055) + // ----------------------------------------------------------------------- + + /// Sim-35: Sprint stance must suppress the interaction buffer entirely. + /// Sim-36: Walk stance must populate the buffer when an NPC is adjacent. + #[test] + fn sprint_suppresses_interaction_buffer_walk_populates() { + // --- Sim-35: Sprint case — buffer must be empty --- + { + let mut world = World::new(); + world.insert_resource(WalkabilityMap::new(20, 20, 1)); + world.init_resource::(); + + let npc_pos = TilePosition::new(6, 5, 0); + world.spawn((Npc, ActiveSim, Interactable, npc_pos)); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + NearbyInteractionBuffer::default(), + Stance(MovementStance::Sprint), + )); + + let mut schedule = Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let mut q = world + .query_filtered::<&mut NearbyInteractionBuffer, With>(); + let mut buf = q.single_mut(&mut world).expect("player must exist"); + let interactions = buf.take(); + assert!( + interactions.is_empty(), + "Sim-35: Sprint stance must suppress the interaction buffer per D-055; \ + found {} interaction(s)", + interactions.len() + ); + } + + // --- Sim-36: Walk case — buffer must contain adjacent NPC --- + { + let mut world = World::new(); + world.insert_resource(WalkabilityMap::new(20, 20, 1)); + world.init_resource::(); + + let npc_pos = TilePosition::new(6, 5, 0); + world.spawn((Npc, ActiveSim, Interactable, npc_pos)); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + NearbyInteractionBuffer::default(), + Stance(MovementStance::Walk), + )); + + let mut schedule = Schedule::default(); + schedule.add_systems(compute_nearby_interactions); + schedule.run(&mut world); + + let mut q = world + .query_filtered::<&mut NearbyInteractionBuffer, With>(); + let mut buf = q.single_mut(&mut world).expect("player must exist"); + let interactions = buf.take(); + assert!( + !interactions.is_empty(), + "Sim-36: Walk stance must populate interaction buffer for a nearby NPC; \ + buffer was empty" + ); + } + } +} diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index 937b65fde..5687fa50b 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -38,6 +38,8 @@ #[cfg(feature = "gauntlet")] pub mod constants; +#[cfg(feature = "gauntlet")] +pub mod invariants; pub mod reset; #[cfg(feature = "gauntlet")] pub mod rooms; @@ -493,6 +495,48 @@ pub fn setup_gauntlet(app: &mut App) { } app.insert_resource(snapshots); + + // --- Sprint 14 component fixup --- + // Attach MoodState and InteractionMemory to all Npc entities that are + // missing them. Gauntlet room builders don't include these yet — this + // ensures invariant S14-1/S14-2 pass and the mood/trust systems have + // valid component targets. + { + use crate::npc::interaction::InteractionMemory; + use crate::npc::mood::MoodState; + use crate::npc::Npc; + + let missing_mood: Vec = { + let mut q = app + .world_mut() + .query_filtered::, + bevy_ecs::prelude::Without, + )>(); + q.iter(app.world()).collect() + }; + for entity in missing_mood { + app.world_mut() + .entity_mut(entity) + .insert(MoodState::default()); + } + + let missing_mem: Vec = { + let mut q = app + .world_mut() + .query_filtered::, + bevy_ecs::prelude::Without, + )>(); + q.iter(app.world()).collect() + }; + for entity in missing_mem { + app.world_mut() + .entity_mut(entity) + .insert(InteractionMemory::default()); + } + } + app.insert_resource(registry); } @@ -536,6 +580,9 @@ mod tests { setup_gauntlet(&mut app); + // Run all 29 world-query invariants against the fully-initialized gauntlet world. + invariants::run_invariants(app.world_mut()); + let registry = app.world().resource::(); assert_eq!( registry.len(), diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 238560d35..efec87058 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -62,6 +62,8 @@ fn snapshot_roundtrip_over_unix_socket() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 3e3d8377b..b75058c1f 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -48,6 +48,8 @@ fn snapshot_roundtrip_over_tcp() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index fb0d6f0a0..5ff8fec22 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -38,6 +38,8 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, } } @@ -214,6 +216,8 @@ fn generate_msgpack_fixtures() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; write_fixture( diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index ff9362f09..7190ded26 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -27,6 +27,8 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, } } @@ -259,6 +261,8 @@ fn snapshot_v2_fields_roundtrip() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; @@ -355,6 +359,8 @@ fn all_facing_direction_variants_roundtrip() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");