From 029337a801e289d035950b5dd9ff44f5347ca42a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:39:27 +0100 Subject: [PATCH] feat(simulation): add personality and tell system (#90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TellCategory enum (Nervous, Angry, Friendly, Guarded, RoutineDeviation) with DerivedTellState component. derive_tell_state system runs after update_mood and detect_routine_deviation. Tell state derived from NPC axis values per D-024: Secret+low Tolerance→Nervous, low Contentment+ Hostile→Angry, high Contentment+Friendly→Friendly, high Secret→Guarded. v0.1 renderer is monologue text, not visual animation. Co-Authored-By: Claude Opus 4.6 --- server/src/npc/tell_state.rs | 576 +++++++++++++++++++++++++++++++++++ 1 file changed, 576 insertions(+) create mode 100644 server/src/npc/tell_state.rs diff --git a/server/src/npc/tell_state.rs b/server/src/npc/tell_state.rs new file mode 100644 index 000000000..2afe99981 --- /dev/null +++ b/server/src/npc/tell_state.rs @@ -0,0 +1,576 @@ +//! Tell state derivation system (#90, D-024 tell system). +//! +//! Derives the current observable tell category from NPC axis values each tick. +//! Tell state is NOT authored per NPC — it flows from simulation state. +//! +//! ## 5 tell categories (D-024) +//! - `Nervous`: Major secret + stress > half the threshold +//! - `Angry`: Contentment < −20 AND Hostile mood +//! - `Friendly`: Contentment > +20 AND at least one relationship with trust > 3 +//! - `Guarded`: Major secret (any stress level) +//! - `RoutineDeviation`: NPC has a `RoutineDeviation` component this tick +//! +//! ## Priority order (highest wins) +//! RoutineDeviation > Nervous > Angry > Guarded > Friendly > None +//! +//! ## v0.1 output +//! `DerivedTellState` is read by the observer snapshot system and emitted into +//! `ObserverSnapshot.entities[].tell_state`. Client renders as monologue text; +//! visual animation is deferred beyond v0.1. + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::npc::mood::{MoodState, NpcMood}; +use crate::npc::{ + Contentment, Npc, Relationships, RoutineDeviation, Secret, SecretSeverity, ToleranceThreshold, +}; +use crate::simulation::tier::ActiveSim; + +// --------------------------------------------------------------------------- +// TellCategory enum +// --------------------------------------------------------------------------- + +/// Observable tell category emitted into the observer snapshot (#90, D-024). +/// +/// Derived each tick from NPC simulation state — not authored per NPC. +/// Five categories correspond to the D-024 tell taxonomy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub enum TellCategory { + /// NPC exhibits nervous behaviour: Major secret + stress exceeds half of threshold. + #[default] + Nervous, + /// NPC exhibits angry behaviour: low contentment and Hostile mood. + Angry, + /// NPC appears warm and open: high contentment with a positively-trusted relationship. + Friendly, + /// NPC appears guarded or evasive: Major secret (any stress). + Guarded, + /// NPC has deviated from their expected routine — primary detective mechanic (D-027). + RoutineDeviation, +} + +// --------------------------------------------------------------------------- +// DerivedTellState component +// --------------------------------------------------------------------------- + +/// Per-NPC component: the current observable tell category this tick. +/// +/// Updated each tick by [`derive_tell_state`] for Active-tier NPCs. +/// `None` means no notable tell is observable (normal / neutral state). +/// +/// Read by the observer snapshot system to populate +/// `ObserverSnapshot.entities[].tell_state`. +#[derive(Component, Debug, Clone, Default)] +pub struct DerivedTellState { + /// Current tell category, or `None` if no tell is active. + pub category: Option, +} + +// --------------------------------------------------------------------------- +// Derivation helpers +// --------------------------------------------------------------------------- + +fn derive_category( + secret: &Secret, + tolerance: &ToleranceThreshold, + contentment: &Contentment, + mood_state: &MoodState, + relationships_opt: Option<&Relationships>, + deviation_opt: Option<&RoutineDeviation>, +) -> Option { + // Priority 1: RoutineDeviation (primary detective mechanic, D-027 criterion 4) + if deviation_opt.is_some() { + return Some(TellCategory::RoutineDeviation); + } + + // Priority 2: Nervous — Major secret with stress past the midpoint + if secret.severity == SecretSeverity::Major + && tolerance.threshold > 0 + && tolerance.current_stress * 2 > tolerance.threshold + { + return Some(TellCategory::Nervous); + } + + // Priority 3: Angry — low contentment combined with Hostile mood + if contentment.level < -20 && mood_state.mood == NpcMood::Hostile { + return Some(TellCategory::Angry); + } + + // Priority 4: Guarded — Major secret at any stress level + if secret.severity == SecretSeverity::Major { + return Some(TellCategory::Guarded); + } + + // Priority 5: Friendly — high contentment with at least one trusted relationship + if contentment.level > 20 { + let has_positive_relationship = relationships_opt + .map(|rels| rels.entries.iter().any(|r| r.trust_level > 3)) + .unwrap_or(false); + if has_positive_relationship { + return Some(TellCategory::Friendly); + } + } + + None +} + +// --------------------------------------------------------------------------- +// Derivation system +// --------------------------------------------------------------------------- + +/// System: derive tell state from NPC axis values for all Active-tier NPCs. +/// +/// Runs after `update_mood` — requires a fresh `MoodState`. +/// Writes the result into `DerivedTellState`, which the observer snapshot +/// system reads to populate `VisibleEntity.tell_state`. +/// +/// Scoped to `ActiveSim`: Background-tier NPCs retain their last-known tell +/// state, consistent with D-026 tier policy. +pub fn derive_tell_state( + mut npcs: Query< + ( + &Secret, + &ToleranceThreshold, + &Contentment, + &MoodState, + Option<&Relationships>, + Option<&RoutineDeviation>, + &mut DerivedTellState, + ), + (With, With), + >, +) { + for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, mut tell) in + npcs.iter_mut() + { + tell.category = derive_category( + secret, + tolerance, + contentment, + mood_state, + relationships_opt, + deviation_opt, + ); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::types::StableId; + use crate::npc::{Relationship, RelationshipKind, RoutineDeviation as NpcRoutineDeviation}; + use crate::npc::{SecretSeverity, ToleranceThreshold}; + use crate::npc::mood::NpcMood; + use crate::npc::DeviationTrigger; + + fn neutral_secret() -> Secret { + Secret { + description: "minor embarrassment".into(), + severity: SecretSeverity::Minor, + known_by: vec![], + } + } + + fn major_secret() -> Secret { + Secret { + description: "criminal record".into(), + severity: SecretSeverity::Major, + known_by: vec![], + } + } + + fn moderate_secret() -> Secret { + Secret { + description: "moderate secret".into(), + severity: SecretSeverity::Moderate, + known_by: vec![], + } + } + + fn tolerance(stress: i16, threshold: i16) -> ToleranceThreshold { + ToleranceThreshold { + current_stress: stress, + threshold, + } + } + + fn contentment(level: i16) -> Contentment { + Contentment { level } + } + + fn mood(m: NpcMood) -> MoodState { + MoodState { mood: m, changed_tick: 0 } + } + + fn positive_relationships() -> Relationships { + Relationships { + entries: vec![Relationship { + target_id: StableId(1), + kind: RelationshipKind::Friend, + trust_level: 5, + history: vec![], + }], + } + } + + fn neutral_relationships() -> Relationships { + Relationships { + entries: vec![Relationship { + target_id: StableId(1), + kind: RelationshipKind::Colleague, + trust_level: 0, + history: vec![], + }], + } + } + + fn deviation() -> NpcRoutineDeviation { + NpcRoutineDeviation { + trigger: DeviationTrigger::WalkAway, + tick: 100, + } + } + + // ----------------------------------------------------------------------- + // Priority 1: RoutineDeviation beats everything + // ----------------------------------------------------------------------- + + #[test] + fn routine_deviation_beats_nervous() { + let result = derive_category( + &major_secret(), + &tolerance(90, 100), // Stress past midpoint — would be Nervous + &contentment(0), + &mood(NpcMood::Neutral), + None, + Some(&deviation()), + ); + assert_eq!(result, Some(TellCategory::RoutineDeviation)); + } + + #[test] + fn routine_deviation_beats_angry() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-50), + &mood(NpcMood::Hostile), + None, + Some(&deviation()), + ); + assert_eq!(result, Some(TellCategory::RoutineDeviation)); + } + + #[test] + fn no_deviation_component_skips_deviation_category() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, // No deviation + ); + assert_ne!(result, Some(TellCategory::RoutineDeviation)); + } + + // ----------------------------------------------------------------------- + // Priority 2: Nervous + // ----------------------------------------------------------------------- + + #[test] + fn major_secret_stress_past_midpoint_is_nervous() { + // stress=60, threshold=100 → stress*2=120 > 100 → nervous + let result = derive_category( + &major_secret(), + &tolerance(60, 100), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_eq!(result, Some(TellCategory::Nervous)); + } + + #[test] + fn major_secret_stress_at_midpoint_is_guarded_not_nervous() { + // stress=50, threshold=100 → stress*2=100 is NOT > 100 → Guarded fallthrough + let result = derive_category( + &major_secret(), + &tolerance(50, 100), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_eq!(result, Some(TellCategory::Guarded)); + } + + #[test] + fn minor_secret_high_stress_not_nervous() { + let result = derive_category( + &neutral_secret(), // Minor — not Major + &tolerance(90, 100), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_ne!(result, Some(TellCategory::Nervous)); + } + + #[test] + fn nervous_requires_nonzero_threshold() { + // threshold=0: stress*2=0 NOT > 0 → skip nervous + let result = derive_category( + &major_secret(), + &tolerance(0, 0), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + // Still Guarded (Major secret, priority 4) + assert_eq!(result, Some(TellCategory::Guarded)); + } + + // ----------------------------------------------------------------------- + // Priority 3: Angry + // ----------------------------------------------------------------------- + + #[test] + fn low_contentment_hostile_mood_is_angry() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-30), // Below -20 + &mood(NpcMood::Hostile), + None, + None, + ); + assert_eq!(result, Some(TellCategory::Angry)); + } + + #[test] + fn hostile_mood_without_low_contentment_not_angry() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(0), // Not low enough + &mood(NpcMood::Hostile), + None, + None, + ); + assert_ne!(result, Some(TellCategory::Angry)); + } + + #[test] + fn low_contentment_without_hostile_mood_not_angry() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-30), + &mood(NpcMood::Anxious), // Not Hostile + None, + None, + ); + assert_ne!(result, Some(TellCategory::Angry)); + } + + #[test] + fn contentment_at_boundary_minus_20_not_angry() { + // -20 is NOT < -20 + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-20), + &mood(NpcMood::Hostile), + None, + None, + ); + assert_ne!(result, Some(TellCategory::Angry)); + } + + // ----------------------------------------------------------------------- + // Priority 4: Guarded + // ----------------------------------------------------------------------- + + #[test] + fn major_secret_low_stress_is_guarded() { + let result = derive_category( + &major_secret(), + &tolerance(5, 100), // Low stress, not nervous + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_eq!(result, Some(TellCategory::Guarded)); + } + + #[test] + fn moderate_secret_not_guarded() { + let result = derive_category( + &moderate_secret(), + &tolerance(0, 50), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + // Moderate secret doesn't trigger Guarded + assert_ne!(result, Some(TellCategory::Guarded)); + } + + // ----------------------------------------------------------------------- + // Priority 5: Friendly + // ----------------------------------------------------------------------- + + #[test] + fn high_contentment_positive_relationship_is_friendly() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), // Above +20 + &mood(NpcMood::Neutral), + Some(&positive_relationships()), // Trust > 3 + None, + ); + assert_eq!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn high_contentment_no_positive_relationship_not_friendly() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + Some(&neutral_relationships()), // Trust = 0 + None, + ); + assert_ne!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn high_contentment_no_relationships_not_friendly() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(30), + &mood(NpcMood::Neutral), + None, // No relationships at all + None, + ); + assert_ne!(result, Some(TellCategory::Friendly)); + } + + #[test] + fn contentment_at_boundary_plus_20_not_friendly() { + // +20 is NOT > 20 + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(20), + &mood(NpcMood::Neutral), + Some(&positive_relationships()), + None, + ); + assert_ne!(result, Some(TellCategory::Friendly)); + } + + // ----------------------------------------------------------------------- + // None result + // ----------------------------------------------------------------------- + + #[test] + fn neutral_npc_returns_none() { + let result = derive_category( + &neutral_secret(), + &tolerance(10, 50), + &contentment(0), + &mood(NpcMood::Neutral), + None, + None, + ); + assert_eq!(result, None); + } + + #[test] + fn no_tell_for_minor_secret_low_stress_neutral_mood() { + let result = derive_category( + &neutral_secret(), + &tolerance(0, 50), + &contentment(-5), // Not low enough for angry + &mood(NpcMood::Anxious), // Not Hostile + None, + None, + ); + assert_eq!(result, None); + } + + // ----------------------------------------------------------------------- + // Bevy ECS integration: system updates DerivedTellState + // ----------------------------------------------------------------------- + + #[test] + fn system_updates_derived_tell_state() { + use bevy_ecs::world::World; + use crate::npc::Npc; + + let mut world = World::new(); + + // Spawn an NPC that should have RoutineDeviation tell + let entity = world + .spawn(( + Npc, + ActiveSim, + major_secret(), + tolerance(60, 100), + contentment(0), + mood(NpcMood::Neutral), + DerivedTellState::default(), + deviation(), // RoutineDeviation present + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(derive_tell_state); + schedule.run(&mut world); + + let state = world.get::(entity).unwrap(); + assert_eq!(state.category, Some(TellCategory::RoutineDeviation)); + } + + #[test] + fn system_sets_none_for_neutral_npc() { + use bevy_ecs::world::World; + use crate::npc::Npc; + + let mut world = World::new(); + + let entity = world + .spawn(( + Npc, + ActiveSim, + neutral_secret(), + tolerance(10, 80), + contentment(5), + mood(NpcMood::Neutral), + DerivedTellState::default(), + )) + .id(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(derive_tell_state); + schedule.run(&mut world); + + let state = world.get::(entity).unwrap(); + assert_eq!(state.category, None); + } +}