Files
settled-reach/server/src/npc/mod.rs
T
jpmschweitzerandClaude Opus 4.6 2fd16f08b0 fix(server): address PR #14 review — ordering, docs, version pin
- Fix NpcPlugin system ordering: .before(compute_paths) instead of
  .after(advance_tick) so PathRequests are picked up same frame
- Fix stale doc comment in interpretation.rs: system runs BEFORE
  knowledge events, not after
- Add TODO(v0.2) on RelationshipGraph about information boundary
  limitation for multiplayer
- Document cardinal-only movement as deliberate v0.1 choice
- Add comment on manhattan_distance u32::MAX fallback for cross-z
- Pin pathfinding crate to 4.11

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:17:55 +01:00

341 lines
10 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 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::<relationships::RelationshipGraph>()
.init_resource::<routine::PreviousDayPhase>()
.add_systems(
Update,
routine::check_phase_transition
.before(crate::simulation::pathfinding::compute_paths),
);
tracing::debug!("NpcPlugin initialized");
}
}
#[derive(Component, Debug)]
pub struct Npc;
// ---------------------------------------------------------------------------
// 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<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)
}
// ---------------------------------------------------------------------------
// 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);
}
}