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:
@@ -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