InteractionMemory component tracks interaction_count, last_interaction_tick, and notable_events per NPC. Drives D-028 Layer 2 situation activation: first_meeting (count==0) and repeated_visit (count>=3). warm_active in mood system now derives from InteractionMemory within a 300-tick window. Trust progression wired into dialogue systems: talk completion (+1), walk-away (-1), confrontation (-2) emit TrustEvents consumed by update_trust. InteractionEvent (WalkAway, Confrontation) recorded in notable_events for fast per-pair access. Adds FirstMeeting and RepeatedVisit Situation variants. 18 unit tests in interaction.rs. All arithmetic integer-only (D-010 determinism). No HashMap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+70
-4
@@ -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<SimulationTime>,
|
||||
mut query: Query<
|
||||
@@ -186,6 +190,7 @@ pub fn update_mood(
|
||||
&mut MoodState,
|
||||
Option<&mut CurrentMood>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&InteractionMemory>,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user