feat(simulation): Sprint 14 — NPC mood, trust, routines, conversations, invariants #50

Merged
jpmschweitzer merged 8 commits from server into main 2026-02-20 19:28:54 +01:00
21 changed files with 4322 additions and 17 deletions
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.12"
version = "0.1.13"
dependencies = [
"bevy_app",
"bevy_ecs",
+3
View File
@@ -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",
}
}
+4
View File
@@ -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,
};
+12 -2
View File
@@ -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<crate::simulation::sound::SoundEvent>,
/// 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<crate::simulation::conversation::ConversationEvent>,
/// Conversations that ended this tick (#247, D-078).
/// Client dismisses the passive dialogue panel for these pairs.
#[serde(default)]
pub conversation_ended: Vec<crate::simulation::conversation::ConversationEndEvent>,
/// 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).
+11
View File
@@ -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(),
+6
View File
@@ -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) {
+179
View File
@@ -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<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
}
}
+21 -2
View File
@@ -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::<relationships::RelationshipGraph>()
.init_resource::<relationships::TrustEventQueue>()
.init_resource::<routine::PreviousDayPhase>()
.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");
+739
View File
@@ -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<SimulationTime>,
mut query: Query<
(
&mut MoodState,
Option<&mut CurrentMood>,
Option<&ToleranceThreshold>,
Option<&InteractionMemory>,
),
(With<Npc>, With<ActiveSim>),
>,
) {
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::<SimulationTime>();
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::<MoodState>(npc).unwrap();
assert_eq!(mood_state.mood, NpcMood::Hostile);
let current_mood = world.get::<CurrentMood>(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::<MoodState>(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::<SimulationTime>().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::<MoodState>(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::<SimulationTime>().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::<MoodState>(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::<MoodState>(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::<CurrentMood>(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::<MoodState>(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::<MoodState>(calm).unwrap().mood, NpcMood::Content);
assert_eq!(
world.get::<MoodState>(stressed).unwrap().mood,
NpcMood::Hostile
);
}
}
+542 -4
View File
@@ -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<TrustEvent>,
}
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<TrustEvent> {
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<Item = &mut RelationshipEdge> {
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<TrustEventQueue>,
mut graph: ResMut<RelationshipGraph>,
registry: Res<EntityRegistry>,
time: Res<SimulationTime>,
) {
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<SimulationTime>, mut graph: ResMut<RelationshipGraph>) {
// 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::<SimulationTime>();
world.init_resource::<EntityRegistry>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<TrustEventQueue>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 5 talk events
for _ in 0..5 {
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 15 talk events — should clamp at 10
for _ in 0..15 {
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// Push 8 confrontation events — 8 * -2 = -16, should clamp at -10
for _ in 0..8 {
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
// 3 talks (+3) then 1 walk-away (-1) then 1 confrontation (-2) = net 0
let mut queue = world.resource_mut::<TrustEventQueue>();
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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<SimulationTime>().tick = 42;
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.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::<EntityRegistry>();
let npc_sid = registry.to_stable(npc).unwrap();
let player_sid = registry.to_stable(player).unwrap();
let graph = world.resource::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Pre-populate with a Friend edge at trust 5
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_sid,
player_sid,
make_edge(RelationshipKind::Friend, 5),
);
world
.resource_mut::<TrustEventQueue>()
.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::<RelationshipGraph>();
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::<EntityRegistry>().register(npc);
world
.resource_mut::<TrustEventQueue>()
.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::<RelationshipGraph>();
assert!(graph.is_empty(), "no edge should be created for unregistered entity");
}
}
+417 -3
View File
@@ -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::<ActivityState>();
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<SimulationTime>,
npcs: Query<
(
Entity,
&TilePosition,
&DailyRoutine,
Option<&ActivityState>,
),
(
With<Npc>,
With<ActiveSim>,
Without<ComputedPath>,
Without<PathRequest>,
),
>,
) {
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::<ActivityState>();
}
} else {
// No routine entry for this phase — remove stale activity
commands.entity(entity).remove::<ActivityState>();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -243,4 +343,318 @@ mod tests {
let request = world.get::<PathRequest>(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::<SimulationTime>().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::<ActivityState>(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::<SimulationTime>().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::<ActivityState>(entity).is_none());
}
#[test]
fn npc_with_computed_path_excluded() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().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::<ActivityState>(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::<SimulationTime>().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::<ActivityState>(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::<SimulationTime>().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::<ActivityState>(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::<SimulationTime>().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::<ActivityState>(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::<SimulationTime>().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::<ActivityState>(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::<SimulationTime>().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::<ActivityState>(entity).is_none(),
"Phase transition should clear ActivityState"
);
// PathRequest should be set for the new phase location
assert!(world.get::<PathRequest>(entity).is_some());
}
}
+11
View File
@@ -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<PlayerCharacter>,
>,
@@ -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()),
});
File diff suppressed because it is too large Load Diff
+65 -5
View File
@@ -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<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
mut player_query: Query<
(
Entity,
@@ -357,7 +360,7 @@ pub fn process_talk_interaction(
),
With<PlayerCharacter>,
>,
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<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
time: Res<SimulationTime>,
query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With<PlayerCharacter>>,
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
) {
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<SimulationTime>,
registry: Res<EntityRegistry>,
mut rng: ResMut<crate::simulation::rng::SimRng>,
mut trust_queue: ResMut<TrustEventQueue>,
mut query: Query<
(
Entity,
@@ -626,6 +666,7 @@ pub fn process_confrontation_response(
),
With<PlayerCharacter>,
>,
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
) {
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::<EntityRegistry>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<TrustEventQueue>();
world
}
@@ -1708,6 +1764,7 @@ mod tests {
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
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::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
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::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
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::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
world.init_resource::<TrustEventQueue>();
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
+4
View File
@@ -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),
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<MoodState>,
)>();
q.iter(app.world()).collect()
};
for entity in missing_mood {
app.world_mut()
.entity_mut(entity)
.insert(MoodState::default());
}
let missing_mem: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<InteractionMemory>,
)>();
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::<EntityRegistry>();
assert_eq!(
registry.len(),
+2
View File
@@ -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,
};
+2
View File
@@ -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,
};
+4
View File
@@ -38,6 +38,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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(
+6
View File
@@ -27,6 +27,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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");