- Sort eligible NPCs by StableId for deterministic conversation pairing (D-010) - Replace single_mut() with per-observer iteration (D-010 principle 3, D-027) - Widen current_stress * 100 to i32 in mood derivation to prevent i16 overflow - Promote line_interval to named constant LINE_INTERVAL_TICKS - Add 4 Sprint 14 component invariants (MoodState, InteractionMemory, ActivityState+PathRequest exclusion, double-tagged tier detection) - Attach MoodState to content spawn pipeline and gauntlet fixup - VecDeque for InteractionMemory.notable_events (O(1) pop_front) - Rename who_knows() to who_knows_full_scan() to communicate O(N) cost - Add O(N^2) growth limit comment on conversation pair scan - Document Suspicious/Focused as externally-set moods on enum variants Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
180 lines
6.2 KiB
Rust
180 lines
6.2 KiB
Rust
//! 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<InteractionEvent>,
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|