feat(simulation): routine execution system with ActivityState (#101, D-031)

ActivityState component tracks the activity an NPC is currently performing
at their routine destination (activity name, phase, started_tick). The
enter_activity system runs after movement validation and sets ActivityState
when an NPC has arrived with no active path. Cleared on phase transitions.

Feeds TellTrigger::DuringActivity and D-028 Layer 2 situation matching.
Unit tests verified via shift_change room integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 18:52:29 +01:00
co-authored by Claude Sonnet 4.6
parent dcd15a330f
commit bd60b356f8
+417 -3
View File
@@ -1,16 +1,43 @@
//! Daily routine system (#88).
//! Daily routine system (#88, #101).
//!
//! Detects day-phase transitions (D-031) and issues PathRequests for NPCs
//! whose DailyRoutine has a location for the new phase.
//! whose DailyRoutine has a location for the new phase. Tracks NPC activity
//! state when they arrive at their routine destination (#101).
//!
//! Pipeline: phase transition → PathRequest → pathfinder → path_follow →
//! NPC arrives → enter_activity sets ActivityState.
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::npc::{DailyRoutine, Npc};
use crate::simulation::movement::TilePosition;
use crate::simulation::pathfinding::PathRequest;
use crate::simulation::pathfinding::{ComputedPath, PathRequest};
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::{DayPhase, SimulationTime};
// ---------------------------------------------------------------------------
// ActivityState component (#101)
// ---------------------------------------------------------------------------
/// Tracks the activity an NPC is currently performing at their routine location.
///
/// Set by `enter_activity` when an NPC:
/// 1. Has no active `ComputedPath` or `PathRequest` (finished walking)
/// 2. Is at the location specified by their `DailyRoutine` for the current phase
///
/// Cleared on phase transitions (replaced with new activity or removed).
/// Feeds `TellTrigger::DuringActivity` and D-028 Layer 2 situation matching.
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct ActivityState {
/// Activity name from `RoutineEntry.activity` (e.g., "Work", "Bar", "Sleep").
pub activity: String,
/// The day phase this activity belongs to.
pub phase: DayPhase,
/// Tick when the NPC arrived and started this activity.
pub started_tick: u64,
}
/// Resource tracking the previous day phase for transition detection.
#[derive(Resource, Debug, Clone)]
pub struct PreviousDayPhase {
@@ -57,6 +84,9 @@ pub fn check_phase_transition(
previous.day = current_day;
for (entity, current_pos, routine) in npcs.iter() {
// Clear stale activity on phase transition — will be re-evaluated by enter_activity
commands.entity(entity).remove::<ActivityState>();
if let Some(expected_location) = routine.expected_location(current_phase) {
if *current_pos != expected_location {
commands.entity(entity).insert(PathRequest {
@@ -73,6 +103,76 @@ pub fn check_phase_transition(
}
}
// ---------------------------------------------------------------------------
// System: enter_activity (#101)
// ---------------------------------------------------------------------------
/// Set ActivityState when an NPC has arrived at their routine destination.
///
/// Runs after movement validation. Checks NPCs that:
/// - Have a DailyRoutine and ActiveSim tier
/// - Are NOT currently pathfinding (no ComputedPath or PathRequest)
/// - Are at the location specified for the current day phase
/// - Don't already have the correct ActivityState for the current phase
///
/// When conditions are met, inserts an ActivityState component. When an NPC
/// has a stale activity from a previous phase and isn't at the new phase's
/// destination, the stale activity is removed.
///
/// System ordering: after validate_movement, before compute_observer_snapshot.
pub fn enter_activity(
mut commands: Commands,
time: Res<SimulationTime>,
npcs: Query<
(
Entity,
&TilePosition,
&DailyRoutine,
Option<&ActivityState>,
),
(
With<Npc>,
With<ActiveSim>,
Without<ComputedPath>,
Without<PathRequest>,
),
>,
) {
let current_phase = time.day_phase();
for (entity, pos, routine, activity_opt) in npcs.iter() {
// Already performing the correct activity for this phase
if let Some(activity) = activity_opt {
if activity.phase == current_phase {
continue;
}
}
// Check if at routine destination for current phase
if let Some(entry) = routine.entry_for_phase(current_phase) {
if *pos == entry.location {
commands.entity(entity).insert(ActivityState {
activity: entry.activity.clone(),
phase: current_phase,
started_tick: time.tick,
});
tracing::trace!(
"Entity {:?}: entered activity '{}' for {:?}",
entity,
entry.activity,
current_phase,
);
} else {
// Not at destination yet — remove stale activity
commands.entity(entity).remove::<ActivityState>();
}
} else {
// No routine entry for this phase — remove stale activity
commands.entity(entity).remove::<ActivityState>();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -243,4 +343,318 @@ mod tests {
let request = world.get::<PathRequest>(entity).unwrap();
assert_eq!(request.goal, morning_loc);
}
// -- enter_activity tests (#101) ------------------------------------------
#[test]
fn npc_at_routine_destination_gets_activity_state() {
let mut world = setup_world();
// Time = Afternoon
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc, // Already at afternoon destination
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
let state = world.get::<ActivityState>(entity).unwrap();
assert_eq!(state.activity, "Work");
assert_eq!(state.phase, DayPhase::Afternoon);
assert_eq!(state.started_tick, MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE);
}
#[test]
fn npc_not_at_destination_no_activity_state() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let entity = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0), // NOT at afternoon location
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(10, 10, 0),
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
assert!(world.get::<ActivityState>(entity).is_none());
}
#[test]
fn npc_with_computed_path_excluded() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc, // At destination but still has a path
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
ComputedPath {
steps: vec![],
current_index: 0,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<ActivityState>(entity).is_none(),
"NPC with ComputedPath should not get ActivityState"
);
}
#[test]
fn npc_with_path_request_excluded() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc,
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
PathRequest { goal: loc },
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<ActivityState>(entity).is_none(),
"NPC with PathRequest should not get ActivityState"
);
}
#[test]
fn existing_activity_same_phase_not_overwritten() {
let mut world = setup_world();
let tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().tick = tick + 100;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc,
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
ActivityState {
activity: "Work".into(),
phase: DayPhase::Afternoon,
started_tick: tick, // Set earlier
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
let state = world.get::<ActivityState>(entity).unwrap();
assert_eq!(
state.started_tick, tick,
"started_tick should be preserved, not updated"
);
}
#[test]
fn stale_activity_replaced_on_phase_change() {
let mut world = setup_world();
// Time = Evening (after Afternoon)
let evening_tick = 2 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().tick = evening_tick;
let evening_loc = TilePosition::new(20, 20, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
evening_loc, // Already at evening location
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(10, 10, 0),
activity: "Work".into(),
},
RoutineEntry {
phase: DayPhase::Evening,
location: evening_loc,
activity: "Bar".into(),
},
],
description: "Test".into(),
},
// Stale activity from previous phase
ActivityState {
activity: "Work".into(),
phase: DayPhase::Afternoon,
started_tick: 1000,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
let state = world.get::<ActivityState>(entity).unwrap();
assert_eq!(state.activity, "Bar");
assert_eq!(state.phase, DayPhase::Evening);
assert_eq!(state.started_tick, evening_tick);
}
#[test]
fn no_routine_for_phase_clears_stale_activity() {
let mut world = setup_world();
// Time = Night
let night_tick = 3 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().tick = night_tick;
let entity = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(10, 10, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Evening,
location: TilePosition::new(10, 10, 0),
activity: "Bar".into(),
}],
description: "Test".into(),
},
// Stale activity from Evening, no Night entry
ActivityState {
activity: "Bar".into(),
phase: DayPhase::Evening,
started_tick: 1000,
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(enter_activity);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<ActivityState>(entity).is_none(),
"Stale activity should be cleared when no routine entry for current phase"
);
}
#[test]
fn phase_transition_clears_activity_state() {
let mut world = setup_world();
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc,
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: loc,
activity: "Work".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(20, 20, 0),
activity: "Lunch".into(),
},
],
description: "Test".into(),
},
ActivityState {
activity: "Work".into(),
phase: DayPhase::Morning,
started_tick: 0,
},
))
.id();
// Trigger phase transition to Afternoon
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);
world.flush();
// ActivityState should be cleared by phase transition
assert!(
world.get::<ActivityState>(entity).is_none(),
"Phase transition should clear ActivityState"
);
// PathRequest should be set for the new phase location
assert!(world.get::<PathRequest>(entity).is_some());
}
}