feat(npc): add structured NPC data model, relationships, and daily routines
Implements tickets #86, #87, #88 for Sprint 3: - Replace stub string/f32 NPC fields with typed enums and integer types for D-010 determinism (WantKind, SecretSeverity, Skill, etc.) - Add RelationshipGraph global resource with BTreeMap<(StableId, StableId), RelationshipEdge> for efficient prefix queries - Add DailyRoutine with phase-based RoutineEntry and PreviousDayPhase resource for detecting day-phase transitions - Create NpcPlugin that initializes relationship graph, day-phase tracking, and registers check_phase_transition system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+281
-18
@@ -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::<relationships::RelationshipGraph>()
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.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<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>,
|
||||
}
|
||||
|
||||
#[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<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 known_facts: Vec<String>,
|
||||
pub facts: Vec<KnownFact>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<String>,
|
||||
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<String>,
|
||||
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: Vec<String>,
|
||||
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: 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RelationshipEvent>,
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -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<SimulationTime>,
|
||||
mut previous: ResMut<PreviousDayPhase>,
|
||||
mut commands: Commands,
|
||||
npcs: Query<(Entity, &TilePosition, &DailyRoutine), With<Npc>>,
|
||||
) {
|
||||
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::<PreviousDayPhase>();
|
||||
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::<SimulationTime>().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::<PathRequest>(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::<PathRequest>(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::<SimulationTime>().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::<PathRequest>(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::<SimulationTime>().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::<PathRequest>(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::<SimulationTime>().tick = night_tick;
|
||||
world.resource_mut::<PreviousDayPhase>().phase = DayPhase::Night;
|
||||
world.resource_mut::<PreviousDayPhase>().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::<SimulationTime>().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::<PathRequest>(entity).unwrap();
|
||||
assert_eq!(request.goal, morning_loc);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user