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>
790 lines
25 KiB
Rust
790 lines
25 KiB
Rust
//! 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)"
|
||
);
|
||
}
|
||
}
|