diff --git a/server/src/bin/line_preview.rs b/server/src/bin/line_preview.rs index 5555923cf..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", } } diff --git a/server/src/content/line_pool.rs b/server/src/content/line_pool.rs index 79524a7de..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(), diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 190d99051..9035f48de 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -111,6 +111,9 @@ 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()); + // 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..a907d2b3a --- /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: Vec, +} + +/// 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.remove(0); + } + self.notable_events.push(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.last().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 dfa65c1bf..5d80bff17 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,6 +2,7 @@ // 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; @@ -22,6 +23,7 @@ pub struct NpcPlugin; impl Plugin for NpcPlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() .init_resource::() .add_systems( Update, @@ -31,6 +33,17 @@ impl Plugin for NpcPlugin { 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), ), ); diff --git a/server/src/npc/mood.rs b/server/src/npc/mood.rs index c492f478f..59599e04b 100644 --- a/server/src/npc/mood.rs +++ b/server/src/npc/mood.rs @@ -19,6 +19,7 @@ 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; @@ -111,6 +112,10 @@ 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): @@ -178,7 +183,6 @@ pub fn derive_mood( /// (D-026). This is intentional: background NPCs simulate passage of time via /// last-known state, not per-tick derivation. /// -/// TODO(#325): wire `warm_active` from `InteractionMemory` when Sprint 14 #325 lands. pub fn update_mood( time: Res, mut query: Query< @@ -186,6 +190,7 @@ pub fn update_mood( &mut MoodState, Option<&mut CurrentMood>, Option<&ToleranceThreshold>, + Option<&InteractionMemory>, ), (With, With), >, @@ -193,13 +198,19 @@ pub fn update_mood( let phase = time.day_phase(); let tick = time.tick; - for (mut mood_state, current_mood_opt, tolerance_opt) in query.iter_mut() { + 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 - // TODO(#325): query InteractionMemory.recent_positive_interaction() - let warm_active = false; + // 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); @@ -626,6 +637,61 @@ mod tests { 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(); 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)))