use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use crate::{ RuntimeCompanyControllerKind, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerTarget, RuntimeState, RuntimeSummary, RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, 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 mutated_player_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, } #[derive(Debug, Default)] struct ResolvedConditionContext { matching_company_ids: BTreeSet, matching_player_ids: BTreeSet, } 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 mutated_player_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_conditions, record_effects, record_marks_collection_dirty, record_one_shot, ) = { let record = &state.event_runtime_records[index]; ( record.record_id, record.conditions.clone(), record.effects.clone(), record.marks_collection_dirty, record.one_shot, ) }; let Some(condition_context) = evaluate_record_conditions(state, &record_conditions)? else { continue; }; let effect_summary = apply_runtime_effects( state, &record_effects, &condition_context, &mut mutated_company_ids, &mut mutated_player_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(), mutated_player_ids: mutated_player_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], condition_context: &ResolvedConditionContext, mutated_company_ids: &mut BTreeSet, mutated_player_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::SetEconomicStatusCode { value } => { state.world_restore.economic_status_code = Some(*value); } RuntimeEffect::SetCompanyCash { target, value } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; 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::SetPlayerCash { target, value } => { let player_ids = resolve_player_target_ids(state, target, condition_context)?; for player_id in player_ids { let player = state .players .iter_mut() .find(|player| player.player_id == player_id) .ok_or_else(|| { format!("missing player_id {player_id} while applying cash effect") })?; player.current_cash = *value; mutated_player_ids.insert(player_id); } } RuntimeEffect::SetCompanyTerritoryAccess { target, territory, value, } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; let territory_ids = resolve_territory_target_ids(state, territory)?; set_company_territory_access_pairs( &mut state.company_territory_access, &company_ids, &territory_ids, *value, ); mutated_company_ids.extend(company_ids); } RuntimeEffect::ConfiscateCompanyAssets { target } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids.iter().copied() { let company = state .companies .iter_mut() .find(|company| company.company_id == company_id) .ok_or_else(|| { format!( "missing company_id {company_id} while applying confiscate effect" ) })?; company.current_cash = 0; company.debt = 0; company.active = false; mutated_company_ids.insert(company_id); if state.selected_company_id == Some(company_id) { state.selected_company_id = None; } } retire_matching_trains(&mut state.trains, Some(&company_ids), None, None); } RuntimeEffect::DeactivateCompany { target } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; 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, condition_context)?; 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::RetireTrains { company_target, territory_target, locomotive_name, } => { let company_ids = company_target .as_ref() .map(|target| resolve_company_target_ids(state, target, condition_context)) .transpose()?; let territory_ids = territory_target .as_ref() .map(|target| resolve_territory_target_ids(state, target)) .transpose()?; retire_matching_trains( &mut state.trains, company_ids.as_ref(), territory_ids.as_ref(), locomotive_name.as_deref(), ); } RuntimeEffect::AdjustCompanyCash { target, delta } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; 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, condition_context)?; 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 evaluate_record_conditions( state: &RuntimeState, conditions: &[RuntimeCondition], ) -> Result, String> { if conditions.is_empty() { return Ok(Some(ResolvedConditionContext::default())); } let mut company_matches: Option> = None; for condition in conditions { match condition { RuntimeCondition::CompanyNumericThreshold { target, metric, comparator, value, } => { let resolved = resolve_company_target_ids( state, target, &ResolvedConditionContext::default(), )?; let matching = resolved .into_iter() .filter(|company_id| { state .companies .iter() .find(|company| company.company_id == *company_id) .is_some_and(|company| { compare_condition_value( company_metric_value(company, *metric), *comparator, *value, ) }) }) .collect::>(); if matching.is_empty() { return Ok(None); } intersect_company_matches(&mut company_matches, matching); if company_matches.as_ref().is_some_and(BTreeSet::is_empty) { return Ok(None); } } RuntimeCondition::TerritoryNumericThreshold { target, metric, comparator, value, } => { let territory_ids = resolve_territory_target_ids(state, target)?; let actual = territory_metric_value(state, &territory_ids, *metric); if !compare_condition_value(actual, *comparator, *value) { return Ok(None); } } RuntimeCondition::CompanyTerritoryNumericThreshold { target, territory, metric, comparator, value, } => { let territory_ids = resolve_territory_target_ids(state, territory)?; let resolved = resolve_company_target_ids( state, target, &ResolvedConditionContext::default(), )?; let matching = resolved .into_iter() .filter(|company_id| { compare_condition_value( company_territory_metric_value( state, *company_id, &territory_ids, *metric, ), *comparator, *value, ) }) .collect::>(); if matching.is_empty() { return Ok(None); } intersect_company_matches(&mut company_matches, matching); if company_matches.as_ref().is_some_and(BTreeSet::is_empty) { return Ok(None); } } } } Ok(Some(ResolvedConditionContext { matching_company_ids: company_matches.unwrap_or_default(), matching_player_ids: BTreeSet::new(), })) } fn intersect_company_matches(company_matches: &mut Option>, next: BTreeSet) { match company_matches { Some(existing) => { existing.retain(|company_id| next.contains(company_id)); } None => { *company_matches = Some(next); } } } fn resolve_company_target_ids( state: &RuntimeState, target: &RuntimeCompanyTarget, condition_context: &ResolvedConditionContext, ) -> 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 => { if condition_context.matching_company_ids.is_empty() { Err("target requires condition-evaluation context".to_string()) } else { Ok(condition_context .matching_company_ids .iter() .copied() .collect()) } } } } fn resolve_player_target_ids( state: &RuntimeState, target: &RuntimePlayerTarget, condition_context: &ResolvedConditionContext, ) -> Result, String> { match target { RuntimePlayerTarget::AllActive => Ok(state .players .iter() .filter(|player| player.active) .map(|player| player.player_id) .collect()), RuntimePlayerTarget::Ids { ids } => { let known_ids = state .players .iter() .map(|player| player.player_id) .collect::>(); for player_id in ids { if !known_ids.contains(player_id) { return Err(format!("target references unknown player_id {player_id}")); } } Ok(ids.clone()) } RuntimePlayerTarget::HumanPlayers => { if state .players .iter() .any(|player| player.controller_kind == RuntimeCompanyControllerKind::Unknown) { return Err( "target requires player role context but at least one player has unknown controller_kind" .to_string(), ); } Ok(state .players .iter() .filter(|player| { player.active && player.controller_kind == RuntimeCompanyControllerKind::Human }) .map(|player| player.player_id) .collect()) } RuntimePlayerTarget::AiPlayers => { if state .players .iter() .any(|player| player.controller_kind == RuntimeCompanyControllerKind::Unknown) { return Err( "target requires player role context but at least one player has unknown controller_kind" .to_string(), ); } Ok(state .players .iter() .filter(|player| { player.active && player.controller_kind == RuntimeCompanyControllerKind::Ai }) .map(|player| player.player_id) .collect()) } RuntimePlayerTarget::SelectedPlayer => { let selected_player_id = state .selected_player_id .ok_or_else(|| "target requires selected_player_id context".to_string())?; if state .players .iter() .any(|player| player.player_id == selected_player_id && player.active) { Ok(vec![selected_player_id]) } else { Err("target requires selected_player_id to reference an active player".to_string()) } } RuntimePlayerTarget::ConditionTruePlayer => { if condition_context.matching_player_ids.is_empty() { Err("target requires player condition-evaluation context".to_string()) } else { Ok(condition_context .matching_player_ids .iter() .copied() .collect()) } } } } fn resolve_territory_target_ids( state: &RuntimeState, target: &RuntimeTerritoryTarget, ) -> Result, String> { match target { RuntimeTerritoryTarget::AllTerritories => Ok(state .territories .iter() .map(|territory| territory.territory_id) .collect()), RuntimeTerritoryTarget::Ids { ids } => { let known_ids = state .territories .iter() .map(|territory| territory.territory_id) .collect::>(); for territory_id in ids { if !known_ids.contains(territory_id) { return Err(format!( "territory target references unknown territory_id {territory_id}" )); } } Ok(ids.clone()) } } } fn company_metric_value(company: &crate::RuntimeCompany, metric: RuntimeCompanyMetric) -> i64 { match metric { RuntimeCompanyMetric::CurrentCash => company.current_cash, RuntimeCompanyMetric::TotalDebt => company.debt as i64, RuntimeCompanyMetric::CreditRating => company.credit_rating_score.unwrap_or(0), RuntimeCompanyMetric::PrimeRate => company.prime_rate.unwrap_or(0), RuntimeCompanyMetric::TrackPiecesTotal => i64::from(company.track_piece_counts.total), RuntimeCompanyMetric::TrackPiecesSingle => i64::from(company.track_piece_counts.single), RuntimeCompanyMetric::TrackPiecesDouble => i64::from(company.track_piece_counts.double), RuntimeCompanyMetric::TrackPiecesTransition => { i64::from(company.track_piece_counts.transition) } RuntimeCompanyMetric::TrackPiecesElectric => i64::from(company.track_piece_counts.electric), RuntimeCompanyMetric::TrackPiecesNonElectric => { i64::from(company.track_piece_counts.non_electric) } } } fn territory_metric_value( state: &RuntimeState, territory_ids: &[u32], metric: RuntimeTerritoryMetric, ) -> i64 { state .territories .iter() .filter(|territory| territory_ids.contains(&territory.territory_id)) .map(|territory| { track_piece_metric_value( territory.track_piece_counts, territory_metric_to_track_metric(metric), ) }) .sum() } fn company_territory_metric_value( state: &RuntimeState, company_id: u32, territory_ids: &[u32], metric: RuntimeTrackMetric, ) -> i64 { state .company_territory_track_piece_counts .iter() .filter(|entry| { entry.company_id == company_id && territory_ids.contains(&entry.territory_id) }) .map(|entry| track_piece_metric_value(entry.track_piece_counts, metric)) .sum() } fn track_piece_metric_value(counts: RuntimeTrackPieceCounts, metric: RuntimeTrackMetric) -> i64 { match metric { RuntimeTrackMetric::Total => i64::from(counts.total), RuntimeTrackMetric::Single => i64::from(counts.single), RuntimeTrackMetric::Double => i64::from(counts.double), RuntimeTrackMetric::Transition => i64::from(counts.transition), RuntimeTrackMetric::Electric => i64::from(counts.electric), RuntimeTrackMetric::NonElectric => i64::from(counts.non_electric), } } fn territory_metric_to_track_metric(metric: RuntimeTerritoryMetric) -> RuntimeTrackMetric { match metric { RuntimeTerritoryMetric::TrackPiecesTotal => RuntimeTrackMetric::Total, RuntimeTerritoryMetric::TrackPiecesSingle => RuntimeTrackMetric::Single, RuntimeTerritoryMetric::TrackPiecesDouble => RuntimeTrackMetric::Double, RuntimeTerritoryMetric::TrackPiecesTransition => RuntimeTrackMetric::Transition, RuntimeTerritoryMetric::TrackPiecesElectric => RuntimeTrackMetric::Electric, RuntimeTerritoryMetric::TrackPiecesNonElectric => RuntimeTrackMetric::NonElectric, } } fn compare_condition_value( actual: i64, comparator: RuntimeConditionComparator, expected: i64, ) -> bool { match comparator { RuntimeConditionComparator::Ge => actual >= expected, RuntimeConditionComparator::Le => actual <= expected, RuntimeConditionComparator::Gt => actual > expected, RuntimeConditionComparator::Lt => actual < expected, RuntimeConditionComparator::Eq => actual == expected, RuntimeConditionComparator::Ne => actual != expected, } } 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")) } } fn retire_matching_trains( trains: &mut [crate::RuntimeTrain], company_ids: Option<&Vec>, territory_ids: Option<&Vec>, locomotive_name: Option<&str>, ) { for train in trains.iter_mut() { if !train.active || train.retired { continue; } if company_ids.is_some_and(|company_ids| !company_ids.contains(&train.owner_company_id)) { continue; } if territory_ids.is_some_and(|territory_ids| { !train .territory_id .is_some_and(|territory_id| territory_ids.contains(&territory_id)) }) { continue; } if locomotive_name.is_some_and(|name| train.locomotive_name.as_deref() != Some(name)) { continue; } train.active = false; train.retired = true; } } fn set_company_territory_access_pairs( access_entries: &mut Vec, company_ids: &[u32], territory_ids: &[u32], value: bool, ) { if value { for company_id in company_ids { for territory_id in territory_ids { if !access_entries.iter().any(|entry| { entry.company_id == *company_id && entry.territory_id == *territory_id }) { access_entries.push(crate::RuntimeCompanyTerritoryAccess { company_id: *company_id, territory_id: *territory_id, }); } } } } else { access_entries.retain(|entry| { !(company_ids.contains(&entry.company_id) && territory_ids.contains(&entry.territory_id)) }); } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use super::*; use crate::{ CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, RuntimeTerritory, RuntimeTerritoryTarget, RuntimeTrackPieceCounts, RuntimeTrain, 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, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], selected_company_id: None, players: Vec::new(), selected_player_id: None, trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 20, debt: 8, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), 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, conditions: Vec::new(), 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, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 20, debt: 2, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 20, debt: 2, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: false, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 3, controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 30, debt: 3, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), 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, conditions: Vec::new(), 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, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 20, debt: 0, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), 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, conditions: Vec::new(), 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 sets_and_clears_company_territory_access_for_resolved_targets() { let mut state = RuntimeState { companies: vec![ RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 0, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 20, debt: 0, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, ], territories: vec![ RuntimeTerritory { territory_id: 7, name: Some("Appalachia".to_string()), track_piece_counts: RuntimeTrackPieceCounts::default(), }, RuntimeTerritory { territory_id: 8, name: Some("Great Plains".to_string()), track_piece_counts: RuntimeTrackPieceCounts::default(), }, ], event_runtime_records: vec![ RuntimeEventRecord { record_id: 21, trigger_kind: 7, active: true, service_count: 0, marks_collection_dirty: false, one_shot: true, has_fired: false, conditions: Vec::new(), effects: vec![RuntimeEffect::SetCompanyTerritoryAccess { target: RuntimeCompanyTarget::SelectedCompany, territory: RuntimeTerritoryTarget::Ids { ids: vec![7, 8] }, value: true, }], }, RuntimeEventRecord { record_id: 22, trigger_kind: 8, active: true, service_count: 0, marks_collection_dirty: false, one_shot: true, has_fired: false, conditions: Vec::new(), effects: vec![RuntimeEffect::SetCompanyTerritoryAccess { target: RuntimeCompanyTarget::SelectedCompany, territory: RuntimeTerritoryTarget::Ids { ids: vec![8] }, value: false, }], }, ], selected_company_id: Some(1), ..state() }; let first = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, ) .expect("territory access grant should succeed"); assert_eq!( state.company_territory_access, vec![ crate::RuntimeCompanyTerritoryAccess { company_id: 1, territory_id: 7, }, crate::RuntimeCompanyTerritoryAccess { company_id: 1, territory_id: 8, }, ] ); assert_eq!(first.service_events[0].mutated_company_ids, vec![1]); execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 8 }, ) .expect("territory access clear should succeed"); assert_eq!( state.company_territory_access, vec![crate::RuntimeCompanyTerritoryAccess { company_id: 1, territory_id: 7, }] ); } #[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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 41, trigger_kind: 5, active: true, marks_collection_dirty: false, one_shot: false, conditions: Vec::new(), 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, conditions: Vec::new(), effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 51, trigger_kind: 0x0a, active: true, marks_collection_dirty: false, one_shot: false, conditions: Vec::new(), 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, conditions: Vec::new(), effects: vec![ RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 64, trigger_kind: 7, active: true, marks_collection_dirty: false, one_shot: false, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 71, trigger_kind: 4, active: true, marks_collection_dirty: false, one_shot: false, conditions: Vec::new(), 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, conditions: Vec::new(), 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, conditions: Vec::new(), effects: vec![RuntimeEffect::ActivateEventRecord { record_id: 999 }], }], ..state() }; let result = execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 6 }, ); assert!(result.is_err()); } #[test] fn applies_economic_status_code_effect() { let mut state = RuntimeState { event_runtime_records: vec![RuntimeEventRecord { record_id: 90, trigger_kind: 6, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, conditions: Vec::new(), effects: vec![RuntimeEffect::SetEconomicStatusCode { value: 3 }], }], ..state() }; execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 6 }, ) .expect("economic-status effect should succeed"); assert_eq!(state.world_restore.economic_status_code, Some(3)); } #[test] fn confiscate_company_assets_zeros_company_and_retires_owned_trains() { let mut state = RuntimeState { companies: vec![ RuntimeCompany { company_id: 1, controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 50, debt: 7, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, RuntimeCompany { company_id: 2, controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 80, debt: 9, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, ], selected_company_id: Some(1), trains: vec![ RuntimeTrain { train_id: 10, owner_company_id: 1, territory_id: None, locomotive_name: Some("Mikado".to_string()), active: true, retired: false, }, RuntimeTrain { train_id: 11, owner_company_id: 2, territory_id: None, locomotive_name: Some("Orca".to_string()), active: true, retired: false, }, ], event_runtime_records: vec![RuntimeEventRecord { record_id: 91, trigger_kind: 6, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, conditions: Vec::new(), effects: vec![RuntimeEffect::ConfiscateCompanyAssets { target: RuntimeCompanyTarget::SelectedCompany, }], }], ..state() }; execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 6 }, ) .expect("confiscation effect should succeed"); assert_eq!(state.companies[0].current_cash, 0); assert_eq!(state.companies[0].debt, 0); assert!(!state.companies[0].active); assert_eq!(state.selected_company_id, None); assert!(state.trains[0].retired); assert!(!state.trains[1].retired); } #[test] fn retire_trains_respects_company_territory_and_locomotive_filters() { let mut state = RuntimeState { territories: vec![ RuntimeTerritory { territory_id: 7, name: Some("Appalachia".to_string()), track_piece_counts: RuntimeTrackPieceCounts::default(), }, RuntimeTerritory { territory_id: 8, name: Some("Great Plains".to_string()), track_piece_counts: RuntimeTrackPieceCounts::default(), }, ], trains: vec![ RuntimeTrain { train_id: 10, owner_company_id: 1, territory_id: Some(7), locomotive_name: Some("Mikado".to_string()), active: true, retired: false, }, RuntimeTrain { train_id: 11, owner_company_id: 1, territory_id: Some(7), locomotive_name: Some("Orca".to_string()), active: true, retired: false, }, RuntimeTrain { train_id: 12, owner_company_id: 1, territory_id: Some(8), locomotive_name: Some("Mikado".to_string()), active: true, retired: false, }, ], event_runtime_records: vec![RuntimeEventRecord { record_id: 92, trigger_kind: 6, active: true, service_count: 0, marks_collection_dirty: false, one_shot: false, has_fired: false, conditions: Vec::new(), effects: vec![RuntimeEffect::RetireTrains { company_target: Some(RuntimeCompanyTarget::SelectedCompany), territory_target: Some(RuntimeTerritoryTarget::Ids { ids: vec![7] }), locomotive_name: Some("Mikado".to_string()), }], }], selected_company_id: Some(1), ..state() }; execute_step_command( &mut state, &StepCommand::ServiceTriggerKind { trigger_kind: 6 }, ) .expect("retire-trains effect should succeed"); assert!(state.trains[0].retired); assert!(!state.trains[1].retired); assert!(!state.trains[2].retired); } }