use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use crate::{ RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate, 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, pub serviced_record_ids: Vec, pub applied_effect_count: u32, pub mutated_company_ids: Vec, pub appended_record_ids: Vec, pub activated_record_ids: Vec, pub deactivated_record_ids: Vec, pub removed_record_ids: Vec, 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, pub service_events: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] enum EventGraphMutation { Append(RuntimeEventRecordTemplate), Activate { record_id: u32 }, Deactivate { record_id: u32 }, Remove { record_id: u32 }, } #[derive(Debug, Default)] struct AppliedEffectsSummary { applied_effect_count: u32, appended_record_ids: Vec, activated_record_ids: Vec, deactivated_record_ids: Vec, removed_record_ids: Vec, } pub fn execute_step_command( state: &mut RuntimeState, command: &StepCommand, ) -> Result { 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, ) -> Result { 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, ) -> u64 { for _ in 0..steps { step_once(state, boundary_events); } steps.into() } fn step_once(state: &mut RuntimeState, boundary_events: &mut Vec) { 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, ) -> Result<(), String> { state.service_state.periodic_boundary_calls += 1; for trigger_kind in PERIODIC_TRIGGER_KIND_ORDER { service_trigger_kind(state, trigger_kind, service_events)?; } Ok(()) } fn service_trigger_kind( state: &mut RuntimeState, trigger_kind: u8, service_events: &mut Vec, ) -> Result<(), String> { let eligible_indices = state .event_runtime_records .iter() .enumerate() .filter(|(_, record)| { record.active && record.trigger_kind == trigger_kind && !(record.one_shot && record.has_fired) }) .map(|(index, _)| index) .collect::>(); let mut serviced_record_ids = Vec::new(); let mut applied_effect_count = 0_u32; let mut mutated_company_ids = BTreeSet::new(); let mut appended_record_ids = Vec::new(); let mut activated_record_ids = Vec::new(); let mut deactivated_record_ids = Vec::new(); let mut removed_record_ids = Vec::new(); let mut staged_event_graph_mutations = Vec::new(); let mut dirty_rerun = false; *state .service_state .trigger_dispatch_counts .entry(trigger_kind) .or_insert(0) += 1; for index in eligible_indices { let (record_id, record_effects, record_marks_collection_dirty, record_one_shot) = { let record = &state.event_runtime_records[index]; ( record.record_id, record.effects.clone(), record.marks_collection_dirty, record.one_shot, ) }; let effect_summary = apply_runtime_effects( state, &record_effects, &mut mutated_company_ids, &mut staged_event_graph_mutations, )?; applied_effect_count += effect_summary.applied_effect_count; appended_record_ids.extend(effect_summary.appended_record_ids); activated_record_ids.extend(effect_summary.activated_record_ids); deactivated_record_ids.extend(effect_summary.deactivated_record_ids); removed_record_ids.extend(effect_summary.removed_record_ids); { let record = &mut state.event_runtime_records[index]; record.service_count += 1; if record_one_shot { record.has_fired = true; } } serviced_record_ids.push(record_id); state.service_state.total_event_record_services += 1; if trigger_kind != 0x0a && record_marks_collection_dirty { dirty_rerun = true; } } commit_staged_event_graph_mutations(state, &staged_event_graph_mutations)?; service_events.push(ServiceEvent { kind: "trigger_dispatch".to_string(), trigger_kind: Some(trigger_kind), serviced_record_ids, applied_effect_count, mutated_company_ids: mutated_company_ids.into_iter().collect(), appended_record_ids, activated_record_ids, deactivated_record_ids, removed_record_ids, dirty_rerun, }); if dirty_rerun { state.service_state.dirty_rerun_count += 1; service_trigger_kind(state, 0x0a, service_events)?; } Ok(()) } fn apply_runtime_effects( state: &mut RuntimeState, effects: &[RuntimeEffect], mutated_company_ids: &mut BTreeSet, staged_event_graph_mutations: &mut Vec, ) -> Result { let mut summary = AppliedEffectsSummary::default(); for effect in effects { match effect { RuntimeEffect::SetWorldFlag { key, value } => { state.world_flags.insert(key.clone(), *value); } RuntimeEffect::SetCompanyCash { target, value } => { let company_ids = resolve_company_target_ids(state, target)?; for company_id in company_ids { let company = state .companies .iter_mut() .find(|company| company.company_id == company_id) .ok_or_else(|| { format!("missing company_id {company_id} while applying cash effect") })?; company.current_cash = *value; mutated_company_ids.insert(company_id); } } RuntimeEffect::DeactivateCompany { target } => { let company_ids = resolve_company_target_ids(state, target)?; for company_id in company_ids { let company = state .companies .iter_mut() .find(|company| company.company_id == company_id) .ok_or_else(|| { format!( "missing company_id {company_id} while applying deactivate effect" ) })?; company.active = false; mutated_company_ids.insert(company_id); if state.selected_company_id == Some(company_id) { state.selected_company_id = None; } } } RuntimeEffect::SetCompanyTrackLayingCapacity { target, value } => { let company_ids = resolve_company_target_ids(state, target)?; for company_id in company_ids { let company = state .companies .iter_mut() .find(|company| company.company_id == company_id) .ok_or_else(|| { format!( "missing company_id {company_id} while applying track capacity effect" ) })?; company.available_track_laying_capacity = *value; mutated_company_ids.insert(company_id); } } RuntimeEffect::AdjustCompanyCash { target, delta } => { let company_ids = resolve_company_target_ids(state, target)?; for company_id in company_ids { let company = state .companies .iter_mut() .find(|company| company.company_id == company_id) .ok_or_else(|| { format!("missing company_id {company_id} while applying cash effect") })?; company.current_cash = company.current_cash.checked_add(*delta).ok_or_else(|| { format!("company_id {company_id} cash adjustment overflow") })?; mutated_company_ids.insert(company_id); } } RuntimeEffect::AdjustCompanyDebt { target, delta } => { let company_ids = resolve_company_target_ids(state, target)?; for company_id in company_ids { let company = state .companies .iter_mut() .find(|company| company.company_id == company_id) .ok_or_else(|| { format!("missing company_id {company_id} while applying debt effect") })?; company.debt = apply_u64_delta(company.debt, *delta, company_id)?; mutated_company_ids.insert(company_id); } } RuntimeEffect::SetCandidateAvailability { name, value } => { state.candidate_availability.insert(name.clone(), *value); } RuntimeEffect::SetSpecialCondition { label, value } => { state.special_conditions.insert(label.clone(), *value); } RuntimeEffect::AppendEventRecord { record } => { staged_event_graph_mutations.push(EventGraphMutation::Append((**record).clone())); summary.appended_record_ids.push(record.record_id); } RuntimeEffect::ActivateEventRecord { record_id } => { staged_event_graph_mutations.push(EventGraphMutation::Activate { record_id: *record_id, }); summary.activated_record_ids.push(*record_id); } RuntimeEffect::DeactivateEventRecord { record_id } => { staged_event_graph_mutations.push(EventGraphMutation::Deactivate { record_id: *record_id, }); summary.deactivated_record_ids.push(*record_id); } RuntimeEffect::RemoveEventRecord { record_id } => { staged_event_graph_mutations.push(EventGraphMutation::Remove { record_id: *record_id, }); summary.removed_record_ids.push(*record_id); } } summary.applied_effect_count += 1; } Ok(summary) } fn commit_staged_event_graph_mutations( state: &mut RuntimeState, staged_event_graph_mutations: &[EventGraphMutation], ) -> Result<(), String> { for mutation in staged_event_graph_mutations { match mutation { EventGraphMutation::Append(record) => { if state .event_runtime_records .iter() .any(|existing| existing.record_id == record.record_id) { return Err(format!( "cannot append duplicate event record_id {}", record.record_id )); } state .event_runtime_records .push(record.clone().into_runtime_record()); } EventGraphMutation::Activate { record_id } => { let record = state .event_runtime_records .iter_mut() .find(|record| record.record_id == *record_id) .ok_or_else(|| { format!("cannot activate missing event record_id {record_id}") })?; record.active = true; } EventGraphMutation::Deactivate { record_id } => { let record = state .event_runtime_records .iter_mut() .find(|record| record.record_id == *record_id) .ok_or_else(|| { format!("cannot deactivate missing event record_id {record_id}") })?; record.active = false; } EventGraphMutation::Remove { record_id } => { let index = state .event_runtime_records .iter() .position(|record| record.record_id == *record_id) .ok_or_else(|| format!("cannot remove missing event record_id {record_id}"))?; state.event_runtime_records.remove(index); } } } state.validate() } fn resolve_company_target_ids( state: &RuntimeState, target: &RuntimeCompanyTarget, ) -> Result, String> { match target { RuntimeCompanyTarget::AllActive => Ok(state .companies .iter() .filter(|company| company.active) .map(|company| company.company_id) .collect()), RuntimeCompanyTarget::Ids { ids } => { let known_ids = state .companies .iter() .map(|company| company.company_id) .collect::>(); for company_id in ids { if !known_ids.contains(company_id) { return Err(format!("target references unknown company_id {company_id}")); } } Ok(ids.clone()) } RuntimeCompanyTarget::HumanCompanies => { if state .companies .iter() .any(|company| company.controller_kind == RuntimeCompanyControllerKind::Unknown) { return Err( "target requires company role context but at least one company has unknown controller_kind" .to_string(), ); } Ok(state .companies .iter() .filter(|company| { company.active && company.controller_kind == RuntimeCompanyControllerKind::Human }) .map(|company| company.company_id) .collect()) } RuntimeCompanyTarget::AiCompanies => { if state .companies .iter() .any(|company| company.controller_kind == RuntimeCompanyControllerKind::Unknown) { return Err( "target requires company role context but at least one company has unknown controller_kind" .to_string(), ); } Ok(state .companies .iter() .filter(|company| { company.active && company.controller_kind == RuntimeCompanyControllerKind::Ai }) .map(|company| company.company_id) .collect()) } RuntimeCompanyTarget::SelectedCompany => { let selected_company_id = state .selected_company_id .ok_or_else(|| "target requires selected_company_id context".to_string())?; if state .companies .iter() .any(|company| company.company_id == selected_company_id && company.active) { Ok(vec![selected_company_id]) } else { Err("target requires selected_company_id to reference an active company" .to_string()) } } RuntimeCompanyTarget::ConditionTrueCompany => { Err("target requires condition-evaluation context".to_string()) } } } fn apply_u64_delta(current: u64, delta: i64, company_id: u32) -> Result { if delta >= 0 { current .checked_add(delta as u64) .ok_or_else(|| format!("company_id {company_id} debt adjustment overflow")) } else { current .checked_sub(delta.unsigned_abs()) .ok_or_else(|| format!("company_id {company_id} debt adjustment underflow")) } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use super::*; use crate::{ CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, RuntimeWorldRestoreState, }; fn state() -> RuntimeState { RuntimeState { calendar: CalendarPoint { year: 1830, month_slot: 0, phase_slot: 0, tick_slot: 0, }, world_flags: BTreeMap::new(), save_profile: RuntimeSaveProfileState::default(), world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: vec![RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 0, active: true, available_track_laying_capacity: None, }], selected_company_id: None, packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), special_conditions: BTreeMap::new(), 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, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::SetWorldFlag { key: "runtime.effect_fired".to_string(), value: true, }], }, RuntimeEventRecord { record_id: 2, trigger_kind: 4, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::AllActive, delta: 5, }], }, RuntimeEventRecord { record_id: 3, trigger_kind: 0x0a, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::SetSpecialCondition { label: "Dirty rerun fired".to_string(), value: 1, }], }, ], ..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.world_flags.get("runtime.effect_fired"), Some(&true)); assert_eq!(state.companies[0].current_cash, 15); assert_eq!(state.special_conditions.get("Dirty rerun fired"), Some(&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) ); assert_eq!(result.service_events.len(), 7); assert_eq!(result.service_events[0].applied_effect_count, 1); assert_eq!( result .service_events .iter() .find(|event| event.trigger_kind == Some(4)) .expect("trigger kind 4 event should be present") .applied_effect_count, 1 ); assert_eq!( result .service_events .iter() .find(|event| event.trigger_kind == Some(0x0a)) .expect("trigger kind 0x0a event should be present") .applied_effect_count, 1 ); assert_eq!( result .service_events .iter() .find(|event| event.trigger_kind == Some(4)) .expect("trigger kind 4 event should be present") .mutated_company_ids, vec![1] ); } #[test] fn applies_company_effects_for_specific_targets() { let mut state = RuntimeState { companies: vec![ RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 5, active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 20, debt: 8, active: true, available_track_laying_capacity: None, }, ], event_runtime_records: vec![RuntimeEventRecord { record_id: 10, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![ RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::Ids { ids: vec![2] }, delta: 4, }, RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::Ids { ids: vec![2] }, delta: -3, }, ], }], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect("targeted company effects should succeed"); assert_eq!(state.companies[0].current_cash, 10); assert_eq!(state.companies[1].current_cash, 24); assert_eq!(state.companies[1].debt, 5); assert_eq!(result.service_events[0].applied_effect_count, 2); assert_eq!(result.service_events[0].mutated_company_ids, vec![2]); } #[test] fn resolves_symbolic_company_targets() { let mut state = RuntimeState { companies: vec![ RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 0, active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 20, debt: 2, active: true, available_track_laying_capacity: None, }, ], selected_company_id: Some(1), event_runtime_records: vec![ RuntimeEventRecord { record_id: 11, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::HumanCompanies, delta: 5, }], }, RuntimeEventRecord { record_id: 12, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::AiCompanies, delta: 3, }], }, RuntimeEventRecord { record_id: 13, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::SelectedCompany, delta: 7, }], }, ], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect("symbolic target effects should succeed"); assert_eq!(state.companies[0].current_cash, 22); assert_eq!(state.companies[0].debt, 0); assert_eq!(state.companies[1].current_cash, 20); assert_eq!(state.companies[1].debt, 5); assert_eq!(result.service_events[0].mutated_company_ids, vec![1, 2]); } #[test] fn rejects_selected_company_target_without_selection_context() { let mut state = RuntimeState { event_runtime_records: vec![RuntimeEventRecord { record_id: 14, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::SelectedCompany, delta: 1, }], }], ..state() }; let error = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect_err("selected company target should require selection context"); assert!(error.contains("selected_company_id")); } #[test] fn rejects_human_or_ai_targets_without_role_context() { let mut state = RuntimeState { event_runtime_records: vec![RuntimeEventRecord { record_id: 15, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::HumanCompanies, delta: 1, }], }], ..state() }; let error = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect_err("human target should require controller metadata"); assert!(error.contains("controller_kind")); } #[test] fn all_active_and_role_targets_exclude_inactive_companies() { let mut state = RuntimeState { companies: vec![ RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 1, active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 20, debt: 2, active: false, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 3, controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 30, debt: 3, active: true, available_track_laying_capacity: None, }, ], event_runtime_records: vec![ RuntimeEventRecord { record_id: 16, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::AllActive, delta: 5, }], }, RuntimeEventRecord { record_id: 17, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::HumanCompanies, delta: 4, }], }, RuntimeEventRecord { record_id: 18, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::AiCompanies, delta: 6, }], }, ], ..state() }; execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect("active-company filtering should succeed"); assert_eq!(state.companies[0].current_cash, 15); assert_eq!(state.companies[1].current_cash, 20); assert_eq!(state.companies[2].current_cash, 35); assert_eq!(state.companies[0].debt, 5); assert_eq!(state.companies[1].debt, 2); assert_eq!(state.companies[2].debt, 9); } #[test] fn deactivating_selected_company_clears_selection() { let mut state = RuntimeState { companies: vec![RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 0, active: true, available_track_laying_capacity: Some(8), }], selected_company_id: Some(1), event_runtime_records: vec![RuntimeEventRecord { record_id: 19, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::DeactivateCompany { target: RuntimeCompanyTarget::SelectedCompany, }], }], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect("deactivate company effect should succeed"); assert!(!state.companies[0].active); assert_eq!(state.selected_company_id, None); assert_eq!(result.service_events[0].mutated_company_ids, vec![1]); } #[test] fn sets_track_laying_capacity_for_resolved_targets() { let mut state = RuntimeState { companies: vec![ RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 0, active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 20, debt: 0, active: true, available_track_laying_capacity: None, }, ], event_runtime_records: vec![RuntimeEventRecord { record_id: 20, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::SetCompanyTrackLayingCapacity { target: RuntimeCompanyTarget::Ids { ids: vec![2] }, value: Some(14), }], }], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect("track capacity effect should succeed"); assert_eq!(state.companies[0].available_track_laying_capacity, None); assert_eq!(state.companies[1].available_track_laying_capacity, Some(14)); assert_eq!(result.service_events[0].mutated_company_ids, vec![2]); } #[test] fn rejects_condition_true_company_target_without_condition_context() { let mut state = RuntimeState { event_runtime_records: vec![RuntimeEventRecord { record_id: 16, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, delta: 1, }], }], ..state() }; let error = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect_err("condition-relative target should remain blocked"); assert!(error.contains("condition-evaluation context")); } #[test] fn one_shot_record_only_fires_once() { let mut state = RuntimeState { event_runtime_records: vec![RuntimeEventRecord { record_id: 20, trigger_kind: 2, active: true, service_count: 0, marks_collection_dirty: false, one_shot: true, has_fired: false, effects: vec![RuntimeEffect::SetWorldFlag { key: "one_shot".to_string(), value: true, }], }], ..state() }; execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 2 }, ) .expect("first one-shot service should succeed"); let second = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 2 }, ) .expect("second one-shot service should succeed"); assert_eq!(state.event_runtime_records[0].service_count, 1); assert!(state.event_runtime_records[0].has_fired); assert_eq!( second.service_events[0].serviced_record_ids, Vec::::new() ); assert_eq!(second.service_events[0].applied_effect_count, 0); } #[test] fn rejects_debt_underflow() { let mut state = RuntimeState { companies: vec![RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 2, active: true, available_track_laying_capacity: None, }], event_runtime_records: vec![RuntimeEventRecord { record_id: 30, trigger_kind: 3, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::AllActive, delta: -3, }], }], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 3 }, ); assert!(result.is_err()); } #[test] fn appended_record_waits_until_later_pass_without_dirty_rerun() { let mut state = RuntimeState { event_runtime_records: vec![RuntimeEventRecord { record_id: 40, trigger_kind: 5, active: true, service_count: 0, marks_collection_dirty: false, one_shot: true, has_fired: false, effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 41, trigger_kind: 5, active: true, marks_collection_dirty: false, one_shot: false, effects: vec![RuntimeEffect::SetWorldFlag { key: "follow_on_later_pass".to_string(), value: true, }], }), }], }], ..state() }; let first = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 5 }, ) .expect("first pass should succeed"); assert_eq!(first.service_events.len(), 1); assert_eq!(first.service_events[0].serviced_record_ids, vec![40]); assert_eq!(first.service_events[0].appended_record_ids, vec![41]); assert_eq!(state.world_flags.get("follow_on_later_pass"), None); assert_eq!(state.event_runtime_records.len(), 2); assert_eq!(state.event_runtime_records[1].service_count, 0); let second = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 5 }, ) .expect("second pass should succeed"); assert_eq!(second.service_events[0].serviced_record_ids, vec![41]); assert_eq!(state.world_flags.get("follow_on_later_pass"), Some(&true)); assert!(state.event_runtime_records[0].has_fired); assert_eq!(state.event_runtime_records[1].service_count, 1); } #[test] fn appended_record_runs_in_dirty_rerun_after_commit() { let mut state = RuntimeState { event_runtime_records: vec![RuntimeEventRecord { record_id: 50, trigger_kind: 1, active: true, service_count: 0, marks_collection_dirty: true, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 51, trigger_kind: 0x0a, active: true, marks_collection_dirty: false, one_shot: false, effects: vec![RuntimeEffect::SetWorldFlag { key: "dirty_rerun_follow_on".to_string(), value: true, }], }), }], }], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 1 }, ) .expect("dirty rerun with follow-on should succeed"); assert_eq!(result.service_events.len(), 2); assert_eq!(result.service_events[0].serviced_record_ids, vec![50]); assert_eq!(result.service_events[0].appended_record_ids, vec![51]); assert_eq!(result.service_events[1].trigger_kind, Some(0x0a)); assert_eq!(result.service_events[1].serviced_record_ids, vec![51]); assert_eq!(state.service_state.dirty_rerun_count, 1); assert_eq!(state.event_runtime_records.len(), 2); assert_eq!(state.event_runtime_records[1].service_count, 1); assert_eq!(state.world_flags.get("dirty_rerun_follow_on"), Some(&true)); } #[test] fn lifecycle_mutations_commit_between_passes() { let mut state = RuntimeState { event_runtime_records: vec![ RuntimeEventRecord { record_id: 60, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: true, has_fired: false, effects: vec![ RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 64, trigger_kind: 7, active: true, marks_collection_dirty: false, one_shot: false, effects: vec![RuntimeEffect::SetCandidateAvailability { name: "Appended Industry".to_string(), value: 1, }], }), }, RuntimeEffect::DeactivateEventRecord { record_id: 61 }, RuntimeEffect::ActivateEventRecord { record_id: 62 }, RuntimeEffect::RemoveEventRecord { record_id: 63 }, ], }, RuntimeEventRecord { record_id: 61, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::SetWorldFlag { key: "deactivated_after_first_pass".to_string(), value: true, }], }, RuntimeEventRecord { record_id: 62, trigger_kind: 7, active: false, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::SetSpecialCondition { label: "Activated On Second Pass".to_string(), value: 1, }], }, RuntimeEventRecord { record_id: 63, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::SetWorldFlag { key: "removed_after_first_pass".to_string(), value: true, }], }, ], ..state() }; let first = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect("first lifecycle pass should succeed"); assert_eq!( first.service_events[0].serviced_record_ids, vec![60, 61, 63] ); assert_eq!(first.service_events[0].appended_record_ids, vec![64]); assert_eq!(first.service_events[0].activated_record_ids, vec![62]); assert_eq!(first.service_events[0].deactivated_record_ids, vec![61]); assert_eq!(first.service_events[0].removed_record_ids, vec![63]); assert_eq!( state .event_runtime_records .iter() .map(|record| (record.record_id, record.active)) .collect::>(), vec![(60, true), (61, false), (62, true), (64, true)] ); let second = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect("second lifecycle pass should succeed"); assert_eq!(second.service_events[0].serviced_record_ids, vec![62, 64]); assert_eq!( state.special_conditions.get("Activated On Second Pass"), Some(&1) ); assert_eq!( state.candidate_availability.get("Appended Industry"), Some(&1) ); assert_eq!( state.world_flags.get("deactivated_after_first_pass"), Some(&true) ); assert_eq!( state.world_flags.get("removed_after_first_pass"), Some(&true) ); } #[test] fn rejects_duplicate_appended_record_id() { let mut state = RuntimeState { event_runtime_records: vec![ RuntimeEventRecord { record_id: 70, trigger_kind: 4, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 71, trigger_kind: 4, active: true, marks_collection_dirty: false, one_shot: false, effects: Vec::new(), }), }], }, RuntimeEventRecord { record_id: 71, trigger_kind: 4, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: Vec::new(), }, ], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 4 }, ); assert!(result.is_err()); } #[test] fn rejects_missing_lifecycle_mutation_target() { let mut state = RuntimeState { event_runtime_records: vec![RuntimeEventRecord { record_id: 80, trigger_kind: 6, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, effects: vec![RuntimeEffect::ActivateEventRecord { record_id: 999 }], }], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 6 }, ); assert!(result.is_err()); } }