Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
513 lines
17 KiB
Rust
513 lines
17 KiB
Rust
// NPC module - NPC entity definitions and AI systems
|
|
// Implements D-024: 10-axis NPC model + CombatCapability component
|
|
// Background tier state machines for schedule, mood, relationships, job
|
|
|
|
pub mod awareness;
|
|
pub mod background;
|
|
pub mod blueprint;
|
|
pub mod disclosure;
|
|
pub mod generate;
|
|
pub mod interaction;
|
|
pub mod mood;
|
|
pub mod relationships;
|
|
pub mod routine;
|
|
pub mod tell_state;
|
|
pub mod tolerance;
|
|
pub mod trait_modifiers;
|
|
pub mod vision;
|
|
|
|
use bevy_app::prelude::*;
|
|
use bevy_ecs::prelude::*;
|
|
use bevy_ecs::schedule::IntoScheduleConfigs;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
|
|
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
|
|
use crate::simulation::movement::TilePosition;
|
|
use crate::simulation::time::DayPhase;
|
|
|
|
/// NPC plugin: initializes NPC-related resources and systems.
|
|
pub struct NpcPlugin;
|
|
|
|
impl Plugin for NpcPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
use crate::tick_phases::TickPhase;
|
|
|
|
app.init_resource::<relationships::RelationshipGraph>()
|
|
.init_resource::<relationships::TrustEventQueue>()
|
|
.init_resource::<relationships::PropagationQueue>()
|
|
.init_resource::<relationships::DelayedTrustQueue>()
|
|
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
|
|
.init_resource::<routine::PreviousDayPhase>()
|
|
.init_resource::<tolerance::ToleranceBreachEventQueue>()
|
|
.init_resource::<routine::RoutineDeviationEventQueue>()
|
|
.init_resource::<disclosure::DisclosureGlobalRateLimit>()
|
|
.init_resource::<trait_modifiers::TraitModifierConfig>()
|
|
// PreInput: routine phase transition (before pathfinding in Movement)
|
|
.add_systems(
|
|
Update,
|
|
routine::check_phase_transition.in_set(TickPhase::PreInput),
|
|
)
|
|
// Input: dialogue systems (consume player talk/walk-away/confrontation actions)
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
crate::simulation::dialogue::process_talk_interaction,
|
|
crate::simulation::dialogue::process_walk_away
|
|
.after(crate::simulation::dialogue::process_talk_interaction),
|
|
crate::simulation::dialogue::process_confrontation_response,
|
|
crate::simulation::dialogue::process_dialogue_response
|
|
.after(crate::simulation::dialogue::process_talk_interaction),
|
|
)
|
|
.in_set(TickPhase::Input),
|
|
)
|
|
// Simulation: NPC behavior — vision, awareness, mood, tolerance, routine,
|
|
// disclosure, background tick. Intra-phase ordering where needed.
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
vision::compute_npc_vision,
|
|
vision::emit_npc_vision_events.after(vision::compute_npc_vision),
|
|
awareness::detect_player_awareness.after(vision::compute_npc_vision),
|
|
mood::update_mood,
|
|
tolerance::check_tolerance_threshold
|
|
.after(mood::update_mood)
|
|
.after(awareness::detect_player_awareness),
|
|
routine::enter_activity,
|
|
routine::detect_routine_deviation.after(routine::enter_activity),
|
|
background::background_tick,
|
|
disclosure::derive_disclosure_candidates,
|
|
disclosure::process_unprompted_disclosure
|
|
.after(disclosure::derive_disclosure_candidates),
|
|
)
|
|
.in_set(TickPhase::Simulation),
|
|
)
|
|
// Storyteller: tell state derivation (reads mood + routine deviation)
|
|
.add_systems(
|
|
Update,
|
|
tell_state::derive_tell_state.in_set(TickPhase::Storyteller),
|
|
)
|
|
// Knowledge: relationships (trust, propagation, dynamics), vision degradation
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
relationships::update_trust,
|
|
relationships::propagate_social_actions.after(relationships::update_trust),
|
|
relationships::update_relationship_dynamics.after(relationships::update_trust),
|
|
vision::degrade_npc_inferences,
|
|
)
|
|
.in_set(TickPhase::Knowledge),
|
|
);
|
|
|
|
tracing::debug!("NpcPlugin initialized");
|
|
}
|
|
}
|
|
|
|
#[derive(Component, Debug)]
|
|
pub struct Npc;
|
|
|
|
/// Voice culture identifier for an NPC entity (D-138).
|
|
///
|
|
/// Stores the culture_id used to key the voice cache. Attached at spawn time
|
|
/// by the content system or generator. Absent NPCs fall back to base text.
|
|
#[derive(Component, Debug, Clone)]
|
|
pub struct NpcVoiceProfile {
|
|
pub culture_id: String,
|
|
}
|
|
|
|
/// NPC animation tier (D-047).
|
|
///
|
|
/// Tier 1 (clear): public daily activities — instantly readable.
|
|
/// Tier 2 (ambiguous): privately motivated behaviors — player sees the action
|
|
/// but cannot determine the intention.
|
|
///
|
|
/// NPCs start at Tier 1. Confrontation (D-063) and other triggers shift to Tier 2.
|
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
|
pub enum AnimationTier {
|
|
/// Clear, readable public activities (walking, working, talking).
|
|
#[default]
|
|
Tier1,
|
|
/// Ambiguous, privately motivated behaviors (pausing, lingering, looking around).
|
|
Tier2,
|
|
}
|
|
|
|
/// Duration (ticks) a storyteller-escalated RoutineDeviation persists.
|
|
/// 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031).
|
|
pub const TELL_ESCALATION_DURATION_TICKS: u64 = 300;
|
|
|
|
/// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2).
|
|
///
|
|
/// Inserted when a player action causes an NPC to break from their scheduled
|
|
/// behavior. Acts as a hook for the storyteller system and affects future
|
|
/// interactions (e.g., second-approach dialogue differences).
|
|
///
|
|
/// `expires_at_tick`: tick at which this component should be removed.
|
|
/// 0 means "never expires" (legacy default for old insertions).
|
|
#[derive(Component, Debug, Clone)]
|
|
pub struct RoutineDeviation {
|
|
pub trigger: DeviationTrigger,
|
|
pub tick: u64,
|
|
/// Tick at which this deviation expires (component removed by cleanup system).
|
|
/// Set to `tick + TELL_ESCALATION_DURATION_TICKS` for time-limited deviations.
|
|
pub expires_at_tick: u64,
|
|
}
|
|
|
|
/// What caused an NPC's routine deviation.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum DeviationTrigger {
|
|
/// Player walked away mid-dialogue (D-064 Phase 2).
|
|
WalkAway,
|
|
/// Player delivered a confrontation (D-063).
|
|
Confrontation,
|
|
/// Storyteller activated a triangle this NPC belongs to (#589, D-024 axis 9).
|
|
TriangleEscalation,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entanglement tag (D-029, #176)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Marks an NPC's narrative entanglement level (D-029).
|
|
///
|
|
/// - `Flat`: background population — no triangle involvement, minimal story role.
|
|
/// - `Mundane`: has routines and personality but no active triangle membership.
|
|
/// - `Intrigue`: participates in at least one triangle — drives narrative tension.
|
|
///
|
|
/// For authored NPCs: determined by YAML `triangle_membership` field.
|
|
/// For procedural NPCs: assigned by the 30/50/20 ratio via SimRng.
|
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum EntanglementTag {
|
|
Flat,
|
|
Mundane,
|
|
Intrigue,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Axis 1: Want (D-024)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum WantKind {
|
|
Wealth,
|
|
Safety,
|
|
Knowledge,
|
|
Connection,
|
|
Power,
|
|
Freedom,
|
|
Justice,
|
|
Revenge,
|
|
Happiness,
|
|
}
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Want {
|
|
pub primary: WantKind,
|
|
pub intensity: u8, // 1-10, integer for determinism (D-010)
|
|
pub description: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Axis 2: Secret / vulnerability (D-024)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum SecretSeverity {
|
|
Minor, // Social embarrassment
|
|
Moderate, // Career-threatening
|
|
Major, // Criminal / life-threatening
|
|
}
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Secret {
|
|
pub description: String,
|
|
pub severity: SecretSeverity,
|
|
pub known_by: Vec<StableId>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Axis 3: Relationships 1-3 (D-024)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum RelationshipKind {
|
|
Colleague,
|
|
Friend,
|
|
Rival,
|
|
Romantic,
|
|
Family,
|
|
Superior,
|
|
Subordinate,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RelationshipEvent {
|
|
pub tick: u64,
|
|
pub description: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Relationship {
|
|
pub target_id: StableId,
|
|
pub kind: RelationshipKind,
|
|
pub trust_level: i8, // -10..+10, integer for determinism (D-010)
|
|
pub history: Vec<RelationshipEvent>,
|
|
}
|
|
|
|
/// Per-NPC relationship slots. D-024: 3 key relationships for Active-tier.
|
|
pub const MAX_KEY_RELATIONSHIPS: usize = 3;
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Relationships {
|
|
pub entries: Vec<Relationship>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Axis 4: Tolerance threshold (D-024)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToleranceThreshold {
|
|
pub current_stress: i16, // 0-100, integer for determinism (D-010)
|
|
pub threshold: i16,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Axis 5: Daily routine (D-024, D-031)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RoutineEntry {
|
|
pub phase: DayPhase,
|
|
pub location: TilePosition,
|
|
pub activity: String,
|
|
}
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DailyRoutine {
|
|
pub entries: Vec<RoutineEntry>,
|
|
pub description: String,
|
|
}
|
|
|
|
impl DailyRoutine {
|
|
/// Get the routine entry for a given day phase.
|
|
pub fn entry_for_phase(&self, phase: DayPhase) -> Option<&RoutineEntry> {
|
|
self.entries.iter().find(|e| e.phase == phase)
|
|
}
|
|
|
|
/// Get the expected location for a given day phase.
|
|
pub fn expected_location(&self, phase: DayPhase) -> Option<TilePosition> {
|
|
self.entry_for_phase(phase).map(|e| e.location)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Axis 6: Information inventory (D-024)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct KnownFact {
|
|
pub fact_id: FactId,
|
|
pub confidence: KnowledgeConfidence,
|
|
}
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct InformationInventory {
|
|
pub facts: Vec<KnownFact>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Axis 7: Contentment (D-024, Gore's thematic axis)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Contentment {
|
|
pub level: i16, // -100..+100, integer for determinism (D-010)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Job performance (D-026 background state machine — feeds from Contentment)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Tracks how well an NPC performs their job role.
|
|
///
|
|
/// Drifts based on `Contentment` level in the background-tier state machine:
|
|
/// - contentment > 0 → score increases toward 100
|
|
/// - contentment < 0 → score decreases toward 0
|
|
/// - contentment = 0 → no change
|
|
///
|
|
/// Updated by `background::background_tick` once per game-minute for
|
|
/// Background-tier NPCs. Persists through tier promotions (D-026).
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct JobPerformance {
|
|
pub score: i16, // 0..=100, integer for determinism (D-010)
|
|
}
|
|
|
|
impl Default for JobPerformance {
|
|
fn default() -> Self {
|
|
Self { score: 50 }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Supporting axis 1: Personality traits (D-024)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum PersonalityTrait {
|
|
Cautious,
|
|
Bold,
|
|
Honest,
|
|
Deceptive,
|
|
Compassionate,
|
|
Ruthless,
|
|
Curious,
|
|
Incurious,
|
|
Social,
|
|
Reclusive,
|
|
}
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PersonalityTraits {
|
|
pub traits: Vec<PersonalityTrait>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Supporting axis 2: Tell system (D-024)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum TellTrigger {
|
|
StressAboveThreshold,
|
|
NearSpecificEntity(StableId),
|
|
DuringActivity(String),
|
|
TimeOfDay(DayPhase),
|
|
Always,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Tell {
|
|
pub trigger: TellTrigger,
|
|
pub behavior: String,
|
|
}
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TellSystem {
|
|
pub tells: Vec<Tell>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Supporting axis 3: Skill set + combat component (D-024)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
pub enum Skill {
|
|
Combat,
|
|
Intimidation,
|
|
Medical,
|
|
Observation,
|
|
Persuasion,
|
|
Piloting,
|
|
Stealth,
|
|
Technical,
|
|
}
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SkillSet {
|
|
pub skills: BTreeMap<Skill, u8>, // Skill -> proficiency (1-10), BTreeMap for determinism
|
|
pub combat_trained: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum CombatStyle {
|
|
Ranged,
|
|
Melee,
|
|
Evasive,
|
|
Defensive,
|
|
}
|
|
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CombatCapability {
|
|
pub weapon_proficiency: u8, // 1-10
|
|
pub combat_style: CombatStyle,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn daily_routine_entry_for_phase() {
|
|
let routine = DailyRoutine {
|
|
entries: vec![
|
|
RoutineEntry {
|
|
phase: DayPhase::Morning,
|
|
location: TilePosition::new(5, 5, 0),
|
|
activity: "Work".into(),
|
|
},
|
|
RoutineEntry {
|
|
phase: DayPhase::Evening,
|
|
location: TilePosition::new(10, 10, 0),
|
|
activity: "Bar".into(),
|
|
},
|
|
],
|
|
description: "Test routine".into(),
|
|
};
|
|
|
|
assert_eq!(
|
|
routine.expected_location(DayPhase::Morning),
|
|
Some(TilePosition::new(5, 5, 0))
|
|
);
|
|
assert_eq!(
|
|
routine.expected_location(DayPhase::Evening),
|
|
Some(TilePosition::new(10, 10, 0))
|
|
);
|
|
assert_eq!(routine.expected_location(DayPhase::Afternoon), None);
|
|
assert_eq!(routine.expected_location(DayPhase::Night), None);
|
|
}
|
|
|
|
#[test]
|
|
fn skill_set_btreemap_deterministic() {
|
|
let mut skills1 = BTreeMap::new();
|
|
skills1.insert(Skill::Combat, 5);
|
|
skills1.insert(Skill::Stealth, 3);
|
|
skills1.insert(Skill::Persuasion, 7);
|
|
|
|
let mut skills2 = BTreeMap::new();
|
|
skills2.insert(Skill::Persuasion, 7);
|
|
skills2.insert(Skill::Combat, 5);
|
|
skills2.insert(Skill::Stealth, 3);
|
|
|
|
// Insertion order doesn't matter — iteration is deterministic
|
|
let keys1: Vec<_> = skills1.keys().collect();
|
|
let keys2: Vec<_> = skills2.keys().collect();
|
|
assert_eq!(keys1, keys2);
|
|
}
|
|
|
|
#[test]
|
|
fn relationship_max_entries() {
|
|
let rels = Relationships {
|
|
entries: vec![
|
|
Relationship {
|
|
target_id: StableId(1),
|
|
kind: RelationshipKind::Friend,
|
|
trust_level: 5,
|
|
history: vec![],
|
|
},
|
|
Relationship {
|
|
target_id: StableId(2),
|
|
kind: RelationshipKind::Colleague,
|
|
trust_level: 2,
|
|
history: vec![],
|
|
},
|
|
Relationship {
|
|
target_id: StableId(3),
|
|
kind: RelationshipKind::Rival,
|
|
trust_level: -3,
|
|
history: vec![],
|
|
},
|
|
],
|
|
};
|
|
assert_eq!(rels.entries.len(), MAX_KEY_RELATIONSHIPS);
|
|
}
|
|
}
|