feat(simulation): Sprint 14 — NPC mood, trust, routines, conversations, invariants #50
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user