rrt/crates/rrt-runtime/src/step.rs

2893 lines
106 KiB
Rust

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<u8>,
pub serviced_record_ids: Vec<u32>,
pub applied_effect_count: u32,
pub mutated_company_ids: Vec<u32>,
pub mutated_player_ids: Vec<u32>,
pub appended_record_ids: Vec<u32>,
pub activated_record_ids: Vec<u32>,
pub deactivated_record_ids: Vec<u32>,
pub removed_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>,
}
#[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<u32>,
activated_record_ids: Vec<u32>,
deactivated_record_ids: Vec<u32>,
removed_record_ids: Vec<u32>,
}
#[derive(Debug, Default)]
struct ResolvedConditionContext {
matching_company_ids: BTreeSet<u32>,
matching_player_ids: BTreeSet<u32>,
}
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>,
) -> 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<ServiceEvent>,
) -> 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::<Vec<_>>();
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<u32>,
mutated_player_ids: &mut BTreeSet<u32>,
staged_event_graph_mutations: &mut Vec<EventGraphMutation>,
) -> Result<AppliedEffectsSummary, String> {
let mut summary = AppliedEffectsSummary::default();
for effect in effects {
match effect {
RuntimeEffect::SetWorldFlag { key, value } => {
state.world_flags.insert(key.clone(), *value);
}
RuntimeEffect::SetLimitedTrackBuildingAmount { value } => {
state.world_restore.limited_track_building_amount = Some(*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::DeactivatePlayer { target } => {
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 deactivate effect"
)
})?;
player.active = false;
mutated_player_ids.insert(player_id);
if state.selected_player_id == Some(player_id) {
state.selected_player_id = None;
}
}
}
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::SetNamedLocomotiveAvailability { name, value } => {
state
.named_locomotive_availability
.insert(name.clone(), u32::from(*value));
}
RuntimeEffect::SetNamedLocomotiveAvailabilityValue { name, value } => {
state
.named_locomotive_availability
.insert(name.clone(), *value);
}
RuntimeEffect::SetNamedLocomotiveCost { name, value } => {
state.named_locomotive_cost.insert(name.clone(), *value);
}
RuntimeEffect::SetCargoProductionSlot { slot, value } => {
state.cargo_production_overrides.insert(*slot, *value);
}
RuntimeEffect::SetTerritoryAccessCost { value } => {
state.world_restore.territory_access_cost = Some(*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<Option<ResolvedConditionContext>, String> {
if conditions.is_empty() {
return Ok(Some(ResolvedConditionContext::default()));
}
let mut company_matches: Option<BTreeSet<u32>> = 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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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::SpecialConditionThreshold {
label,
comparator,
value,
} => {
let actual = state
.special_conditions
.get(label)
.copied()
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::CandidateAvailabilityThreshold {
name,
comparator,
value,
} => {
let actual = state
.candidate_availability
.get(name)
.copied()
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::NamedLocomotiveAvailabilityThreshold {
name,
comparator,
value,
} => {
let actual = state
.named_locomotive_availability
.get(name)
.copied()
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::NamedLocomotiveCostThreshold {
name,
comparator,
value,
} => {
let actual = state
.named_locomotive_cost
.get(name)
.copied()
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::CargoProductionSlotThreshold {
slot,
comparator,
value,
..
} => {
let actual = state
.cargo_production_overrides
.get(slot)
.copied()
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::CargoProductionTotalThreshold { comparator, value } => {
let actual = state
.cargo_production_overrides
.values()
.copied()
.map(i64::from)
.sum::<i64>();
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::LimitedTrackBuildingAmountThreshold { comparator, value } => {
let actual = state
.world_restore
.limited_track_building_amount
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::TerritoryAccessCostThreshold { comparator, value } => {
let actual = state
.world_restore
.territory_access_cost
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::EconomicStatusCodeThreshold { comparator, value } => {
let actual = state
.world_restore
.economic_status_code
.map(i64::from)
.unwrap_or(0);
if !compare_condition_value(actual, *comparator, *value) {
return Ok(None);
}
}
RuntimeCondition::WorldFlagEquals { key, value } => {
let actual = state.world_flags.get(key).copied().unwrap_or(false);
if actual != *value {
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<BTreeSet<u32>>, next: BTreeSet<u32>) {
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<Vec<u32>, 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::<BTreeSet<_>>();
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<Vec<u32>, 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::<BTreeSet<_>>();
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<Vec<u32>, 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::<BTreeSet<_>>();
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<u64, String> {
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<u32>>,
territory_ids: Option<&Vec<u32>>,
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<crate::RuntimeCompanyTerritoryAccess>,
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, RuntimePlayer,
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(),
locomotive_catalog: Vec::new(),
cargo_catalog: 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(),
named_locomotive_availability: BTreeMap::new(),
named_locomotive_cost: BTreeMap::new(),
cargo_production_overrides: 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 applies_named_locomotive_availability_effects() {
let mut state = RuntimeState {
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::SetNamedLocomotiveAvailability {
name: "Big Boy".to_string(),
value: false,
},
RuntimeEffect::SetNamedLocomotiveAvailability {
name: "GP7".to_string(),
value: true,
},
],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("named locomotive availability effects should succeed");
assert_eq!(state.named_locomotive_availability.get("Big Boy"), Some(&0));
assert_eq!(state.named_locomotive_availability.get("GP7"), Some(&1));
assert_eq!(result.service_events[0].applied_effect_count, 2);
}
#[test]
fn applies_named_locomotive_cost_effects() {
let mut state = RuntimeState {
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::SetNamedLocomotiveCost {
name: "Big Boy".to_string(),
value: 250000,
},
RuntimeEffect::SetNamedLocomotiveCost {
name: "GP7".to_string(),
value: 175000,
},
],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("named locomotive cost effects should succeed");
assert_eq!(state.named_locomotive_cost.get("Big Boy"), Some(&250000));
assert_eq!(state.named_locomotive_cost.get("GP7"), Some(&175000));
assert_eq!(result.service_events[0].applied_effect_count, 2);
}
#[test]
fn applies_scalar_named_locomotive_availability_effects() {
let mut state = RuntimeState {
event_runtime_records: vec![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::SetNamedLocomotiveAvailabilityValue {
name: "Big Boy".to_string(),
value: 42,
},
RuntimeEffect::SetNamedLocomotiveAvailabilityValue {
name: "GP7".to_string(),
value: 7,
},
],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("scalar named locomotive availability effects should succeed");
assert_eq!(
state.named_locomotive_availability.get("Big Boy"),
Some(&42)
);
assert_eq!(state.named_locomotive_availability.get("GP7"), Some(&7));
assert_eq!(result.service_events[0].applied_effect_count, 2);
}
#[test]
fn applies_world_scalar_override_effects() {
let mut state = RuntimeState {
event_runtime_records: vec![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::SetCargoProductionSlot {
slot: 1,
value: 125,
},
RuntimeEffect::SetTerritoryAccessCost { value: 750000 },
],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("world scalar override effects should succeed");
assert_eq!(state.cargo_production_overrides.get(&1), Some(&125));
assert_eq!(state.world_restore.territory_access_cost, Some(750000));
assert_eq!(result.service_events[0].applied_effect_count, 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 deactivating_selected_player_clears_selection() {
let mut state = RuntimeState {
players: vec![RuntimePlayer {
player_id: 1,
current_cash: 500,
active: true,
controller_kind: RuntimeCompanyControllerKind::Human,
}],
selected_player_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::DeactivatePlayer {
target: crate::RuntimePlayerTarget::SelectedPlayer,
}],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("deactivate player effect should succeed");
assert!(!state.players[0].active);
assert_eq!(state.selected_player_id, None);
assert_eq!(result.service_events[0].mutated_player_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 evaluates_world_state_conditions_before_effects_run() {
let mut state = RuntimeState {
world_restore: RuntimeWorldRestoreState {
economic_status_code: Some(3),
..RuntimeWorldRestoreState::default()
},
world_flags: BTreeMap::from([(
String::from("world.disable_stock_buying_and_selling"),
true,
)]),
candidate_availability: BTreeMap::from([(String::from("Mogul"), 2)]),
special_conditions: BTreeMap::from([(String::from("Use Wartime Cargos"), 1)]),
event_runtime_records: vec![
RuntimeEventRecord {
record_id: 23,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: vec![
RuntimeCondition::SpecialConditionThreshold {
label: "Use Wartime Cargos".to_string(),
comparator: RuntimeConditionComparator::Ge,
value: 1,
},
RuntimeCondition::CandidateAvailabilityThreshold {
name: "Mogul".to_string(),
comparator: RuntimeConditionComparator::Eq,
value: 2,
},
RuntimeCondition::EconomicStatusCodeThreshold {
comparator: RuntimeConditionComparator::Eq,
value: 3,
},
RuntimeCondition::WorldFlagEquals {
key: "world.disable_stock_buying_and_selling".to_string(),
value: true,
},
],
effects: vec![RuntimeEffect::SetWorldFlag {
key: "world_condition_passed".to_string(),
value: true,
}],
},
RuntimeEventRecord {
record_id: 24,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: vec![RuntimeCondition::SpecialConditionThreshold {
label: "Disable Cargo Economy".to_string(),
comparator: RuntimeConditionComparator::Gt,
value: 0,
}],
effects: vec![RuntimeEffect::SetWorldFlag {
key: "world_condition_failed".to_string(),
value: true,
}],
},
],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("world-state conditions should evaluate successfully");
assert_eq!(result.service_events[0].serviced_record_ids, vec![23]);
assert_eq!(state.world_flags.get("world_condition_passed"), Some(&true));
assert_eq!(state.world_flags.get("world_condition_failed"), None);
}
#[test]
fn evaluates_world_scalar_conditions_before_effects_run() {
let mut state = RuntimeState {
world_restore: RuntimeWorldRestoreState {
limited_track_building_amount: Some(18),
territory_access_cost: Some(750000),
..RuntimeWorldRestoreState::default()
},
named_locomotive_availability: BTreeMap::from([(String::from("Big Boy"), 42)]),
named_locomotive_cost: BTreeMap::from([(String::from("GP7"), 175000)]),
cargo_production_overrides: BTreeMap::from([(1, 125), (2, 75)]),
event_runtime_records: vec![
RuntimeEventRecord {
record_id: 25,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: vec![
RuntimeCondition::NamedLocomotiveAvailabilityThreshold {
name: "Big Boy".to_string(),
comparator: RuntimeConditionComparator::Eq,
value: 42,
},
RuntimeCondition::NamedLocomotiveCostThreshold {
name: "GP7".to_string(),
comparator: RuntimeConditionComparator::Eq,
value: 175000,
},
RuntimeCondition::CargoProductionSlotThreshold {
slot: 1,
label: "Cargo Production Slot 1".to_string(),
comparator: RuntimeConditionComparator::Eq,
value: 125,
},
RuntimeCondition::CargoProductionTotalThreshold {
comparator: RuntimeConditionComparator::Eq,
value: 200,
},
RuntimeCondition::LimitedTrackBuildingAmountThreshold {
comparator: RuntimeConditionComparator::Eq,
value: 18,
},
RuntimeCondition::TerritoryAccessCostThreshold {
comparator: RuntimeConditionComparator::Eq,
value: 750000,
},
],
effects: vec![RuntimeEffect::SetWorldFlag {
key: "world_scalar_condition_passed".to_string(),
value: true,
}],
},
RuntimeEventRecord {
record_id: 26,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
conditions: vec![RuntimeCondition::NamedLocomotiveAvailabilityThreshold {
name: "Missing Loco".to_string(),
comparator: RuntimeConditionComparator::Gt,
value: 0,
}],
effects: vec![RuntimeEffect::SetWorldFlag {
key: "world_scalar_condition_failed".to_string(),
value: true,
}],
},
],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("world-scalar conditions should evaluate successfully");
assert_eq!(result.service_events[0].serviced_record_ids, vec![25]);
assert_eq!(
state.world_flags.get("world_scalar_condition_passed"),
Some(&true)
);
assert_eq!(state.world_flags.get("world_scalar_condition_failed"), None);
}
#[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::<u32>::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<_>>(),
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 applies_limited_track_building_amount_effect() {
let mut state = RuntimeState {
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::SetLimitedTrackBuildingAmount { value: 18 }],
}],
..state()
};
execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 6 },
)
.expect("limited-track-building-amount effect should succeed");
assert_eq!(state.world_restore.limited_track_building_amount, Some(18));
}
#[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);
}
}