2026-04-10 01:22:47 -07:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
use crate::{RuntimeState, RuntimeSummary, calendar::BoundaryEventKind};
|
|
|
|
|
|
|
|
|
|
const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4];
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
|
|
|
pub enum StepCommand {
|
|
|
|
|
AdvanceTo { calendar: crate::CalendarPoint },
|
|
|
|
|
StepCount { steps: u32 },
|
|
|
|
|
ServiceTriggerKind { trigger_kind: u8 },
|
|
|
|
|
ServicePeriodicBoundary,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StepCommand {
|
|
|
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
|
|
|
match self {
|
|
|
|
|
Self::AdvanceTo { calendar } => calendar.validate(),
|
|
|
|
|
Self::StepCount { steps } => {
|
|
|
|
|
if *steps == 0 {
|
|
|
|
|
return Err("step_count command requires steps > 0".to_string());
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
Self::ServiceTriggerKind { .. } | Self::ServicePeriodicBoundary => Ok(()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct BoundaryEvent {
|
|
|
|
|
pub kind: String,
|
|
|
|
|
pub calendar: crate::CalendarPoint,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct ServiceEvent {
|
|
|
|
|
pub kind: String,
|
|
|
|
|
pub trigger_kind: Option<u8>,
|
|
|
|
|
pub serviced_record_ids: Vec<u32>,
|
|
|
|
|
pub dirty_rerun: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct StepResult {
|
|
|
|
|
pub initial_summary: RuntimeSummary,
|
|
|
|
|
pub final_summary: RuntimeSummary,
|
|
|
|
|
pub steps_executed: u64,
|
|
|
|
|
pub boundary_events: Vec<BoundaryEvent>,
|
|
|
|
|
pub service_events: Vec<ServiceEvent>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn execute_step_command(
|
|
|
|
|
state: &mut RuntimeState,
|
|
|
|
|
command: &StepCommand,
|
|
|
|
|
) -> Result<StepResult, String> {
|
|
|
|
|
state.validate()?;
|
|
|
|
|
command.validate()?;
|
|
|
|
|
|
|
|
|
|
let initial_summary = RuntimeSummary::from_state(state);
|
|
|
|
|
let mut boundary_events = Vec::new();
|
|
|
|
|
let mut service_events = Vec::new();
|
|
|
|
|
let steps_executed = match command {
|
|
|
|
|
StepCommand::AdvanceTo { calendar } => {
|
|
|
|
|
advance_to_target_calendar_point(state, *calendar, &mut boundary_events)?
|
|
|
|
|
}
|
|
|
|
|
StepCommand::StepCount { steps } => step_count(state, *steps, &mut boundary_events),
|
|
|
|
|
StepCommand::ServiceTriggerKind { trigger_kind } => {
|
|
|
|
|
service_trigger_kind(state, *trigger_kind, &mut service_events);
|
|
|
|
|
0
|
|
|
|
|
}
|
|
|
|
|
StepCommand::ServicePeriodicBoundary => {
|
|
|
|
|
service_periodic_boundary(state, &mut service_events);
|
|
|
|
|
0
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let final_summary = RuntimeSummary::from_state(state);
|
|
|
|
|
|
|
|
|
|
Ok(StepResult {
|
|
|
|
|
initial_summary,
|
|
|
|
|
final_summary,
|
|
|
|
|
steps_executed,
|
|
|
|
|
boundary_events,
|
|
|
|
|
service_events,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn advance_to_target_calendar_point(
|
|
|
|
|
state: &mut RuntimeState,
|
|
|
|
|
target: crate::CalendarPoint,
|
|
|
|
|
boundary_events: &mut Vec<BoundaryEvent>,
|
|
|
|
|
) -> Result<u64, String> {
|
|
|
|
|
target.validate()?;
|
|
|
|
|
if target < state.calendar {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"advance_to target {:?} is earlier than current calendar {:?}",
|
|
|
|
|
target, state.calendar
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut steps = 0_u64;
|
|
|
|
|
while state.calendar < target {
|
|
|
|
|
step_once(state, boundary_events);
|
|
|
|
|
steps += 1;
|
|
|
|
|
}
|
|
|
|
|
Ok(steps)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn step_count(
|
|
|
|
|
state: &mut RuntimeState,
|
|
|
|
|
steps: u32,
|
|
|
|
|
boundary_events: &mut Vec<BoundaryEvent>,
|
|
|
|
|
) -> u64 {
|
|
|
|
|
for _ in 0..steps {
|
|
|
|
|
step_once(state, boundary_events);
|
|
|
|
|
}
|
|
|
|
|
steps.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn step_once(state: &mut RuntimeState, boundary_events: &mut Vec<BoundaryEvent>) {
|
|
|
|
|
let boundary = state.calendar.step_forward();
|
|
|
|
|
if boundary != BoundaryEventKind::Tick {
|
|
|
|
|
boundary_events.push(BoundaryEvent {
|
|
|
|
|
kind: boundary_kind_label(boundary).to_string(),
|
|
|
|
|
calendar: state.calendar,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn boundary_kind_label(boundary: BoundaryEventKind) -> &'static str {
|
|
|
|
|
match boundary {
|
|
|
|
|
BoundaryEventKind::Tick => "tick",
|
|
|
|
|
BoundaryEventKind::PhaseRollover => "phase_rollover",
|
|
|
|
|
BoundaryEventKind::MonthRollover => "month_rollover",
|
|
|
|
|
BoundaryEventKind::YearRollover => "year_rollover",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn service_periodic_boundary(state: &mut RuntimeState, service_events: &mut Vec<ServiceEvent>) {
|
|
|
|
|
state.service_state.periodic_boundary_calls += 1;
|
|
|
|
|
|
|
|
|
|
for trigger_kind in PERIODIC_TRIGGER_KIND_ORDER {
|
|
|
|
|
service_trigger_kind(state, trigger_kind, service_events);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn service_trigger_kind(
|
|
|
|
|
state: &mut RuntimeState,
|
|
|
|
|
trigger_kind: u8,
|
|
|
|
|
service_events: &mut Vec<ServiceEvent>,
|
|
|
|
|
) {
|
|
|
|
|
let mut serviced_record_ids = Vec::new();
|
|
|
|
|
let mut dirty_rerun = false;
|
|
|
|
|
|
|
|
|
|
*state
|
|
|
|
|
.service_state
|
|
|
|
|
.trigger_dispatch_counts
|
|
|
|
|
.entry(trigger_kind)
|
|
|
|
|
.or_insert(0) += 1;
|
|
|
|
|
|
|
|
|
|
for record in &mut state.event_runtime_records {
|
|
|
|
|
if record.active && record.trigger_kind == trigger_kind {
|
|
|
|
|
record.service_count += 1;
|
|
|
|
|
serviced_record_ids.push(record.record_id);
|
|
|
|
|
state.service_state.total_event_record_services += 1;
|
|
|
|
|
if trigger_kind != 0x0a && record.marks_collection_dirty {
|
|
|
|
|
dirty_rerun = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
service_events.push(ServiceEvent {
|
|
|
|
|
kind: "trigger_dispatch".to_string(),
|
|
|
|
|
trigger_kind: Some(trigger_kind),
|
|
|
|
|
serviced_record_ids,
|
|
|
|
|
dirty_rerun,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if dirty_rerun {
|
|
|
|
|
state.service_state.dirty_rerun_count += 1;
|
|
|
|
|
service_trigger_kind(state, 0x0a, service_events);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
use super::*;
|
2026-04-11 18:12:25 -07:00
|
|
|
use crate::{
|
|
|
|
|
CalendarPoint, RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState,
|
|
|
|
|
RuntimeServiceState, RuntimeWorldRestoreState,
|
|
|
|
|
};
|
2026-04-10 01:22:47 -07:00
|
|
|
|
|
|
|
|
fn state() -> RuntimeState {
|
|
|
|
|
RuntimeState {
|
|
|
|
|
calendar: CalendarPoint {
|
|
|
|
|
year: 1830,
|
|
|
|
|
month_slot: 0,
|
|
|
|
|
phase_slot: 0,
|
|
|
|
|
tick_slot: 0,
|
|
|
|
|
},
|
|
|
|
|
world_flags: BTreeMap::new(),
|
2026-04-11 18:12:25 -07:00
|
|
|
save_profile: RuntimeSaveProfileState::default(),
|
|
|
|
|
world_restore: RuntimeWorldRestoreState::default(),
|
|
|
|
|
metadata: BTreeMap::new(),
|
2026-04-10 01:22:47 -07:00
|
|
|
companies: vec![RuntimeCompany {
|
|
|
|
|
company_id: 1,
|
|
|
|
|
current_cash: 10,
|
|
|
|
|
debt: 0,
|
|
|
|
|
}],
|
|
|
|
|
event_runtime_records: Vec::new(),
|
2026-04-11 18:12:25 -07:00
|
|
|
candidate_availability: BTreeMap::new(),
|
|
|
|
|
special_conditions: BTreeMap::new(),
|
2026-04-10 01:22:47 -07:00
|
|
|
service_state: RuntimeServiceState::default(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn advances_to_target() {
|
|
|
|
|
let mut state = state();
|
|
|
|
|
let result = execute_step_command(
|
|
|
|
|
&mut state,
|
|
|
|
|
&StepCommand::AdvanceTo {
|
|
|
|
|
calendar: CalendarPoint {
|
|
|
|
|
year: 1830,
|
|
|
|
|
month_slot: 0,
|
|
|
|
|
phase_slot: 0,
|
|
|
|
|
tick_slot: 5,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("advance_to should succeed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(result.steps_executed, 5);
|
|
|
|
|
assert_eq!(state.calendar.tick_slot, 5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_backward_target() {
|
|
|
|
|
let mut state = state();
|
|
|
|
|
state.calendar.tick_slot = 3;
|
|
|
|
|
|
|
|
|
|
let result = execute_step_command(
|
|
|
|
|
&mut state,
|
|
|
|
|
&StepCommand::AdvanceTo {
|
|
|
|
|
calendar: CalendarPoint {
|
|
|
|
|
year: 1830,
|
|
|
|
|
month_slot: 0,
|
|
|
|
|
phase_slot: 0,
|
|
|
|
|
tick_slot: 2,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn services_periodic_trigger_order_and_dirty_rerun() {
|
|
|
|
|
let mut state = RuntimeState {
|
|
|
|
|
event_runtime_records: vec![
|
|
|
|
|
RuntimeEventRecord {
|
|
|
|
|
record_id: 1,
|
|
|
|
|
trigger_kind: 1,
|
|
|
|
|
active: true,
|
|
|
|
|
service_count: 0,
|
|
|
|
|
marks_collection_dirty: true,
|
|
|
|
|
},
|
|
|
|
|
RuntimeEventRecord {
|
|
|
|
|
record_id: 2,
|
|
|
|
|
trigger_kind: 4,
|
|
|
|
|
active: true,
|
|
|
|
|
service_count: 0,
|
|
|
|
|
marks_collection_dirty: false,
|
|
|
|
|
},
|
|
|
|
|
RuntimeEventRecord {
|
|
|
|
|
record_id: 3,
|
|
|
|
|
trigger_kind: 0x0a,
|
|
|
|
|
active: true,
|
|
|
|
|
service_count: 0,
|
|
|
|
|
marks_collection_dirty: false,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
..state()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let result = execute_step_command(&mut state, &StepCommand::ServicePeriodicBoundary)
|
|
|
|
|
.expect("periodic boundary service should succeed");
|
|
|
|
|
|
|
|
|
|
assert_eq!(result.steps_executed, 0);
|
|
|
|
|
assert_eq!(state.service_state.periodic_boundary_calls, 1);
|
|
|
|
|
assert_eq!(state.service_state.total_event_record_services, 3);
|
|
|
|
|
assert_eq!(state.service_state.dirty_rerun_count, 1);
|
|
|
|
|
assert_eq!(state.event_runtime_records[0].service_count, 1);
|
|
|
|
|
assert_eq!(state.event_runtime_records[1].service_count, 1);
|
|
|
|
|
assert_eq!(state.event_runtime_records[2].service_count, 1);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.service_state.trigger_dispatch_counts.get(&1),
|
|
|
|
|
Some(&1)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.service_state.trigger_dispatch_counts.get(&4),
|
|
|
|
|
Some(&1)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
state.service_state.trigger_dispatch_counts.get(&0x0a),
|
|
|
|
|
Some(&1)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|