Files
settled-reach/server/src/npc/tell_state.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

790 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::RelationshipState;
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, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum TellCategory {
/// NPC exhibits nervous behaviour: Major secret + stress exceeds half of threshold.
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>,
kg_opt: Option<&KnowledgeGraph>,
) -> 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
// D-082: prefer KG relationship state over ground-truth axis data.
// Self-axis components (secret, stress, contentment, mood) remain ground-truth;
// OTHER-entity relationship assessment uses the knowledge graph.
if contentment.level > 20 {
let has_positive_relationship = if let Some(kg) = kg_opt {
// D-082: use knowledge graph for other-entity relationship assessment
kg.known_entities_iter()
.any(|(_, ek)| ek.relationship == RelationshipState::Friendly)
} else {
// No KG: fall through to ground-truth axis data
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>,
Option<&KnowledgeGraph>,
&mut DerivedTellState,
),
(With<Npc>, With<ActiveSim>),
>,
) {
for (
secret,
tolerance,
contentment,
mood_state,
relationships_opt,
deviation_opt,
kg_opt,
mut tell,
) in npcs.iter_mut()
{
tell.category = derive_category(
secret,
tolerance,
contentment,
mood_state,
relationships_opt,
deviation_opt,
kg_opt,
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::types::StableId;
use crate::npc::mood::NpcMood;
use crate::npc::DeviationTrigger;
use crate::npc::{Relationship, RelationshipKind, RoutineDeviation as NpcRoutineDeviation};
use crate::npc::{SecretSeverity, ToleranceThreshold};
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,
expires_at_tick: 500,
}
}
// -----------------------------------------------------------------------
// 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()),
None,
);
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()),
None,
);
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
None,
);
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
None,
);
assert_eq!(result, None);
}
// -----------------------------------------------------------------------
// Bevy ECS integration: system updates DerivedTellState
// -----------------------------------------------------------------------
#[test]
fn system_updates_derived_tell_state() {
use crate::npc::Npc;
use bevy_ecs::world::World;
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 crate::npc::Npc;
use bevy_ecs::world::World;
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);
}
// -----------------------------------------------------------------------
// D-082: KG-aware Friendly tell
// -----------------------------------------------------------------------
fn kg_with_friendly_entity() -> KnowledgeGraph {
use crate::simulation::movement::TilePosition;
let mut kg = KnowledgeGraph::new();
kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10);
kg.set_relationship(&StableId(1), RelationshipState::Friendly);
kg
}
fn kg_with_known_entity() -> KnowledgeGraph {
use crate::simulation::movement::TilePosition;
let mut kg = KnowledgeGraph::new();
kg.observe_entity(StableId(1), TilePosition::new(5, 5, 0), 10);
kg.set_relationship(&StableId(1), RelationshipState::Known);
kg
}
#[test]
fn kg_friendly_relationship_triggers_friendly_tell() {
let kg = kg_with_friendly_entity();
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
None, // Relationships component doesn't matter when KG exists
None,
Some(&kg),
);
assert_eq!(result, Some(TellCategory::Friendly));
}
#[test]
fn kg_known_relationship_does_not_trigger_friendly_tell() {
// Known != Friendly — only Friendly relationship triggers the tell
let kg = kg_with_known_entity();
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
None,
None,
Some(&kg),
);
assert_ne!(result, Some(TellCategory::Friendly));
}
#[test]
fn kg_empty_does_not_trigger_friendly_tell() {
let kg = KnowledgeGraph::new();
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
None,
None,
Some(&kg),
);
assert_ne!(result, Some(TellCategory::Friendly));
}
#[test]
fn no_kg_falls_through_to_relationships_component() {
// Without KG, the old behavior (Relationships component) should work
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
Some(&positive_relationships()),
None,
None, // No KG
);
assert_eq!(result, Some(TellCategory::Friendly));
}
#[test]
fn kg_overrides_relationships_component() {
// KG says no Friendly relationships, even though Relationships
// component has trust > 3 — KG wins (D-082).
let kg = kg_with_known_entity(); // Known, not Friendly
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
Some(&positive_relationships()), // ground-truth says Friendly
None,
Some(&kg), // KG says Known (not Friendly)
);
assert_ne!(
result,
Some(TellCategory::Friendly),
"KG should override Relationships component for Friendly tell"
);
}
// -----------------------------------------------------------------------
// Priority chain: verify ordering at boundaries (QA gap closure)
// -----------------------------------------------------------------------
#[test]
fn nervous_beats_angry_when_both_conditions_met() {
// NPC has: Major secret, stress past midpoint (Nervous), AND
// low contentment + Hostile mood (Angry).
// Priority 2 (Nervous) must win over Priority 3 (Angry).
let result = derive_category(
&major_secret(),
&tolerance(80, 100), // stress*2=160 > 100 → Nervous
&contentment(-50), // < -20 → Angry condition met
&mood(NpcMood::Hostile), // Angry condition met
None,
None,
None,
);
assert_eq!(
result,
Some(TellCategory::Nervous),
"Nervous (priority 2) must beat Angry (priority 3)"
);
}
#[test]
fn angry_beats_guarded_when_both_conditions_met() {
// NPC has: Major secret (Guarded), AND low contentment + Hostile (Angry).
// Priority 3 (Angry) must win over Priority 4 (Guarded).
// Note: stress is LOW so Nervous does not trigger.
let result = derive_category(
&major_secret(),
&tolerance(10, 100), // Low stress — not Nervous
&contentment(-30), // < -20 → Angry
&mood(NpcMood::Hostile),
None,
None,
None,
);
assert_eq!(
result,
Some(TellCategory::Angry),
"Angry (priority 3) must beat Guarded (priority 4)"
);
}
#[test]
fn guarded_beats_friendly_when_both_conditions_met() {
// NPC has: Major secret (Guarded), AND high contentment with positive
// relationship (Friendly). Priority 4 (Guarded) must win over Priority 5.
let result = derive_category(
&major_secret(),
&tolerance(0, 100), // Low stress — not Nervous
&contentment(50), // > +20 → Friendly condition met
&mood(NpcMood::Neutral),
Some(&positive_relationships()), // Friendly condition met
None,
None,
);
assert_eq!(
result,
Some(TellCategory::Guarded),
"Guarded (priority 4) must beat Friendly (priority 5)"
);
}
}