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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user