diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 5498ffae0..27c3f532e 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,76 +2,339 @@ // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job +pub mod relationships; +pub mod routine; + +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) { + app.init_resource::() + .init_resource::() + .add_systems( + Update, + routine::check_phase_transition + .after(crate::simulation::time::advance_tick), + ); + + tracing::debug!("NpcPlugin initialized"); + } +} #[derive(Component, Debug)] pub struct Npc; -// 7 essential axes (D-024) +// --------------------------------------------------------------------------- +// 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, +} + #[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, } +// --------------------------------------------------------------------------- +// 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, +} + +/// 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, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Relationship { - /// Wire-format entity ID of the relationship target (scales to 10K+ NPCs) - pub target_id: u64, - pub kind: String, - pub trust_level: f32, -} +// --------------------------------------------------------------------------- +// Axis 4: Tolerance threshold (D-024) +// --------------------------------------------------------------------------- #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct ToleranceThreshold { - pub current_stress: f32, - pub threshold: f32, + 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, 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 { + 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 known_facts: Vec, + pub facts: Vec, } +// --------------------------------------------------------------------------- +// Axis 7: Contentment (D-024, Gore's thematic axis) +// --------------------------------------------------------------------------- + #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct Contentment { - pub level: f32, + pub level: i16, // -100..+100, integer for determinism (D-010) +} + +// --------------------------------------------------------------------------- +// 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, } -// 3 supporting axes (D-024) #[derive(Component, Debug, Clone, Serialize, Deserialize)] pub struct PersonalityTraits { - pub traits: Vec, + pub traits: Vec, +} + +// --------------------------------------------------------------------------- +// 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, + pub tells: Vec, +} + +// --------------------------------------------------------------------------- +// 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: Vec, + pub skills: BTreeMap, // 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: f32, - pub combat_style: String, + 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); + } } diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs new file mode 100644 index 000000000..5be72258f --- /dev/null +++ b/server/src/npc/relationships.rs @@ -0,0 +1,206 @@ +//! Global relationship graph resource (D-024). +//! +//! Tracks how entities feel about each other. Separate from KnowledgeGraph +//! (what entities know) — this is what entities feel. +//! BTreeMap with tuple key (subject, target) for deterministic iteration +//! and efficient prefix queries via range(). + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::knowledge::types::StableId; + +use super::{RelationshipEvent, RelationshipKind}; + +/// Edge in the relationship graph. Directed: A's feelings about B. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelationshipEdge { + pub kind: RelationshipKind, + pub trust: i8, // -10..+10, integer for determinism (D-010) + pub history: Vec, + pub last_interaction_tick: u64, +} + +/// Global relationship graph resource. +/// BTreeMap<(subject, target), edge> for deterministic iteration (D-010). +/// Directed graph: edge (A, B) represents how A feels about B. +#[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)] +pub struct RelationshipGraph { + edges: BTreeMap<(StableId, StableId), RelationshipEdge>, +} + +impl RelationshipGraph { + pub fn new() -> Self { + Self { + edges: BTreeMap::new(), + } + } + + /// Set or update a relationship edge. + pub fn set_relationship( + &mut self, + subject: StableId, + target: StableId, + edge: RelationshipEdge, + ) { + self.edges.insert((subject, target), edge); + } + + /// Get a relationship edge (how subject feels about target). + pub fn get_relationship( + &self, + subject: &StableId, + target: &StableId, + ) -> Option<&RelationshipEdge> { + self.edges.get(&(*subject, *target)) + } + + /// Get all relationships for a subject (who the subject has feelings about). + /// Uses BTreeMap range query: all edges with matching subject are contiguous. + pub fn relationships_of(&self, subject: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { + self.edges + .range((*subject, StableId(0))..=(*subject, StableId(u64::MAX))) + .map(|((_, target), edge)| (target, edge)) + .collect() + } + + /// Get all entities who have feelings about a target. + /// Full scan — use for event detection, not per-tick queries. + pub fn who_knows(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { + self.edges + .iter() + .filter(|((_, t), _)| t == target) + .map(|((s, _), edge)| (s, edge)) + .collect() + } + + /// Update trust level for an existing relationship. + /// Clamps to -10..+10. Returns false if edge does not exist. + pub fn adjust_trust(&mut self, subject: &StableId, target: &StableId, delta: i8) -> bool { + if let Some(edge) = self.edges.get_mut(&(*subject, *target)) { + edge.trust = edge.trust.saturating_add(delta).clamp(-10, 10); + true + } else { + false + } + } + + /// Number of edges in the graph. + pub fn edge_count(&self) -> usize { + self.edges.len() + } + + /// Whether the graph has any edges. + pub fn is_empty(&self) -> bool { + self.edges.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_edge(kind: RelationshipKind, trust: i8) -> RelationshipEdge { + RelationshipEdge { + kind, + trust, + history: vec![], + last_interaction_tick: 0, + } + } + + #[test] + fn set_and_get_relationship() { + let mut graph = RelationshipGraph::new(); + let a = StableId(1); + let b = StableId(2); + + graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 5)); + + let edge = graph.get_relationship(&a, &b).unwrap(); + assert_eq!(edge.kind, RelationshipKind::Friend); + assert_eq!(edge.trust, 5); + + // Reverse direction is empty + assert!(graph.get_relationship(&b, &a).is_none()); + } + + #[test] + fn relationships_of_subject() { + let mut graph = RelationshipGraph::new(); + let a = StableId(1); + + graph.set_relationship(a, StableId(10), make_edge(RelationshipKind::Friend, 5)); + graph.set_relationship(a, StableId(20), make_edge(RelationshipKind::Colleague, 2)); + graph.set_relationship(a, StableId(30), make_edge(RelationshipKind::Rival, -3)); + // Different subject — should not appear + graph.set_relationship(StableId(2), StableId(10), make_edge(RelationshipKind::Family, 8)); + + let rels = graph.relationships_of(&a); + assert_eq!(rels.len(), 3); + // BTreeMap iteration order: sorted by target StableId + assert_eq!(*rels[0].0, StableId(10)); + assert_eq!(*rels[1].0, StableId(20)); + assert_eq!(*rels[2].0, StableId(30)); + } + + #[test] + fn who_knows_target() { + let mut graph = RelationshipGraph::new(); + let target = StableId(10); + + graph.set_relationship(StableId(1), target, make_edge(RelationshipKind::Friend, 5)); + graph.set_relationship(StableId(2), target, make_edge(RelationshipKind::Rival, -2)); + graph.set_relationship(StableId(3), target, make_edge(RelationshipKind::Colleague, 0)); + // Edge to different target — should not appear + graph.set_relationship(StableId(1), StableId(99), make_edge(RelationshipKind::Family, 8)); + + let knowers = graph.who_knows(&target); + assert_eq!(knowers.len(), 3); + } + + #[test] + fn adjust_trust_clamps() { + let mut graph = RelationshipGraph::new(); + let a = StableId(1); + let b = StableId(2); + graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 8)); + + // Positive overflow clamps at +10 + assert!(graph.adjust_trust(&a, &b, 5)); + assert_eq!(graph.get_relationship(&a, &b).unwrap().trust, 10); + + // Negative underflow clamps at -10 + assert!(graph.adjust_trust(&a, &b, -25)); + assert_eq!(graph.get_relationship(&a, &b).unwrap().trust, -10); + } + + #[test] + fn adjust_trust_missing_edge_returns_false() { + let mut graph = RelationshipGraph::new(); + assert!(!graph.adjust_trust(&StableId(1), &StableId(2), 1)); + } + + #[test] + fn empty_graph() { + let graph = RelationshipGraph::new(); + assert!(graph.is_empty()); + assert_eq!(graph.edge_count(), 0); + } + + #[test] + fn deterministic_iteration() { + let mut graph = RelationshipGraph::new(); + // Insert in arbitrary order + graph.set_relationship(StableId(3), StableId(1), make_edge(RelationshipKind::Rival, -1)); + graph.set_relationship(StableId(1), StableId(2), make_edge(RelationshipKind::Friend, 5)); + graph.set_relationship(StableId(2), StableId(3), make_edge(RelationshipKind::Colleague, 0)); + + // Iteration order should be deterministic (sorted by (subject, target)) + let keys: Vec<_> = graph.edges.keys().collect(); + assert_eq!(*keys[0], (StableId(1), StableId(2))); + assert_eq!(*keys[1], (StableId(2), StableId(3))); + assert_eq!(*keys[2], (StableId(3), StableId(1))); + } +} diff --git a/server/src/npc/routine.rs b/server/src/npc/routine.rs new file mode 100644 index 000000000..ee157d967 --- /dev/null +++ b/server/src/npc/routine.rs @@ -0,0 +1,242 @@ +//! Daily routine system (#88). +//! +//! Detects day-phase transitions (D-031) and issues PathRequests for NPCs +//! whose DailyRoutine has a location for the new phase. + +use bevy_ecs::prelude::*; + +use crate::npc::{DailyRoutine, Npc}; +use crate::simulation::movement::TilePosition; +use crate::simulation::pathfinding::PathRequest; +use crate::simulation::time::{DayPhase, SimulationTime}; + +/// Resource tracking the previous day phase for transition detection. +#[derive(Resource, Debug, Clone)] +pub struct PreviousDayPhase { + pub phase: DayPhase, + pub day: u64, +} + +impl Default for PreviousDayPhase { + fn default() -> Self { + Self { + phase: DayPhase::Morning, + day: 0, + } + } +} + +/// System: detect day-phase transitions and issue PathRequests for NPC routines. +/// Runs after advance_tick so the current phase is up-to-date. +pub fn check_phase_transition( + time: Res, + mut previous: ResMut, + mut commands: Commands, + npcs: Query<(Entity, &TilePosition, &DailyRoutine), With>, +) { + let current_phase = time.day_phase(); + let current_day = time.day(); + + if current_phase == previous.phase && current_day == previous.day { + return; + } + + tracing::debug!( + "Day phase transition: {:?} -> {:?} (day {} -> {})", + previous.phase, + current_phase, + previous.day, + current_day + ); + + previous.phase = current_phase; + previous.day = current_day; + + for (entity, current_pos, routine) in npcs.iter() { + if let Some(expected_location) = routine.expected_location(current_phase) { + if *current_pos != expected_location { + commands + .entity(entity) + .insert(PathRequest { + goal: expected_location, + }); + tracing::trace!( + "Entity {:?}: routine path request to {:?} for {:?}", + entity, + expected_location, + current_phase + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::npc::RoutineEntry; + use crate::simulation::time::{MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE}; + + fn setup_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.insert_resource(SimulationTime::default()); + world.init_resource::(); + world + } + + #[test] + fn phase_transition_generates_path_request() { + let mut world = setup_world(); + + let afternoon_loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), // Not at afternoon location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: afternoon_loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + // Advance time to Afternoon boundary + world.resource_mut::().tick = + MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(check_phase_transition); + schedule.run(&mut world); + + let request = world.get::(entity).unwrap(); + assert_eq!(request.goal, afternoon_loc); + } + + #[test] + fn no_transition_no_request() { + let mut world = setup_world(); + + let entity = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: TilePosition::new(10, 10, 0), + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + // Time still at Morning (tick 0), same as PreviousDayPhase default + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(check_phase_transition); + schedule.run(&mut world); + + assert!(world.get::(entity).is_none()); + } + + #[test] + fn npc_already_at_destination_no_request() { + let mut world = setup_world(); + + let loc = TilePosition::new(10, 10, 0); + let entity = world + .spawn(( + Npc, + loc, // Already at afternoon location + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Afternoon, + location: loc, + activity: "Work".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + world.resource_mut::().tick = + MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(check_phase_transition); + schedule.run(&mut world); + + assert!(world.get::(entity).is_none()); + } + + #[test] + fn npc_without_routine_entry_ignored() { + let mut world = setup_world(); + + let entity = world + .spawn(( + Npc, + TilePosition::new(5, 5, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Morning, + location: TilePosition::new(5, 5, 0), + activity: "Sleep".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + // Transition to Afternoon, but NPC only has Morning entry + world.resource_mut::().tick = + MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(check_phase_transition); + schedule.run(&mut world); + + assert!(world.get::(entity).is_none()); + } + + #[test] + fn day_rollover_triggers_morning_routine() { + let mut world = setup_world(); + + // Start at Night + let night_tick = 1080 * TICKS_PER_GAME_MINUTE; + world.resource_mut::().tick = night_tick; + world.resource_mut::().phase = DayPhase::Night; + world.resource_mut::().day = 0; + + let morning_loc = TilePosition::new(3, 3, 0); + let entity = world + .spawn(( + Npc, + TilePosition::new(20, 20, 0), + DailyRoutine { + entries: vec![RoutineEntry { + phase: DayPhase::Morning, + location: morning_loc, + activity: "Wake up".into(), + }], + description: "Test".into(), + }, + )) + .id(); + + // Advance to next day's Morning (day 1, tick 0 of new day) + world.resource_mut::().tick = 1440 * TICKS_PER_GAME_MINUTE; + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(check_phase_transition); + schedule.run(&mut world); + + let request = world.get::(entity).unwrap(); + assert_eq!(request.goal, morning_loc); + } +}