//! 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 } }