feat(simulation): add personality and tell system (#90)

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 14:39:27 +01:00
co-authored by Claude Opus 4.6
parent 78c43ab802
commit 029337a801
+576
View File
@@ -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<TellCategory>,
}
// ---------------------------------------------------------------------------
// Derivation helpers
// ---------------------------------------------------------------------------
fn derive_category(
secret: &Secret,
tolerance: &ToleranceThreshold,
contentment: &Contentment,
mood_state: &MoodState,
relationships_opt: Option<&Relationships>,
deviation_opt: Option<&RoutineDeviation>,
) -> Option<TellCategory> {
// 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<Npc>, With<ActiveSim>),
>,
) {
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::<DerivedTellState>(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::<DerivedTellState>(entity).unwrap();
assert_eq!(state.category, None);
}
}