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

545 lines
18 KiB
Rust
Raw Normal View History

use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::CalendarPoint;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeCompany {
pub company_id: u32,
pub current_cash: i64,
pub debt: u64,
}
2026-04-14 19:37:53 -07:00
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RuntimeCompanyTarget {
AllActive,
Ids { ids: Vec<u32> },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RuntimeEffect {
SetWorldFlag {
key: String,
value: bool,
},
AdjustCompanyCash {
target: RuntimeCompanyTarget,
delta: i64,
},
AdjustCompanyDebt {
target: RuntimeCompanyTarget,
delta: i64,
},
SetCandidateAvailability {
name: String,
value: u32,
},
SetSpecialCondition {
label: String,
value: u32,
},
AppendEventRecord {
record: Box<RuntimeEventRecordTemplate>,
},
ActivateEventRecord {
record_id: u32,
},
DeactivateEventRecord {
record_id: u32,
},
RemoveEventRecord {
record_id: u32,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeEventRecordTemplate {
pub record_id: u32,
pub trigger_kind: u8,
pub active: bool,
#[serde(default)]
pub marks_collection_dirty: bool,
#[serde(default)]
pub one_shot: bool,
#[serde(default)]
pub effects: Vec<RuntimeEffect>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeEventRecord {
pub record_id: u32,
pub trigger_kind: u8,
pub active: bool,
#[serde(default)]
pub service_count: u32,
#[serde(default)]
pub marks_collection_dirty: bool,
2026-04-14 19:37:53 -07:00
#[serde(default)]
pub one_shot: bool,
#[serde(default)]
pub has_fired: bool,
#[serde(default)]
pub effects: Vec<RuntimeEffect>,
}
impl RuntimeEventRecordTemplate {
pub fn into_runtime_record(self) -> RuntimeEventRecord {
RuntimeEventRecord {
record_id: self.record_id,
trigger_kind: self.trigger_kind,
active: self.active,
service_count: 0,
marks_collection_dirty: self.marks_collection_dirty,
one_shot: self.one_shot,
has_fired: false,
effects: self.effects,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RuntimeServiceState {
#[serde(default)]
pub periodic_boundary_calls: u64,
#[serde(default)]
pub trigger_dispatch_counts: BTreeMap<u8, u64>,
#[serde(default)]
pub total_event_record_services: u64,
#[serde(default)]
pub dirty_rerun_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RuntimeSaveProfileState {
#[serde(default)]
pub profile_kind: Option<String>,
#[serde(default)]
pub profile_family: Option<String>,
#[serde(default)]
pub map_path: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub selected_year_profile_lane: Option<u8>,
#[serde(default)]
pub sandbox_enabled: Option<bool>,
#[serde(default)]
pub campaign_scenario_enabled: Option<bool>,
#[serde(default)]
pub staged_profile_copy_on_restore: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RuntimeWorldRestoreState {
#[serde(default)]
pub selected_year_profile_lane: Option<u8>,
#[serde(default)]
pub campaign_scenario_enabled: Option<bool>,
#[serde(default)]
pub sandbox_enabled: Option<bool>,
#[serde(default)]
pub seed_tuple_written_from_raw_lane: Option<bool>,
#[serde(default)]
pub absolute_counter_requires_shell_context: Option<bool>,
#[serde(default)]
pub absolute_counter_reconstructible_from_save: Option<bool>,
#[serde(default)]
pub disable_cargo_economy_special_condition_slot: Option<u8>,
#[serde(default)]
pub disable_cargo_economy_special_condition_reconstructible_from_save: Option<bool>,
#[serde(default)]
pub disable_cargo_economy_special_condition_write_side_grounded: Option<bool>,
#[serde(default)]
pub disable_cargo_economy_special_condition_enabled: Option<bool>,
#[serde(default)]
pub use_bio_accelerator_cars_enabled: Option<bool>,
#[serde(default)]
pub use_wartime_cargos_enabled: Option<bool>,
#[serde(default)]
pub disable_train_crashes_enabled: Option<bool>,
#[serde(default)]
pub disable_train_crashes_and_breakdowns_enabled: Option<bool>,
#[serde(default)]
pub ai_ignore_territories_at_startup_enabled: Option<bool>,
#[serde(default)]
pub absolute_counter_restore_kind: Option<String>,
#[serde(default)]
pub absolute_counter_adjustment_context: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeState {
pub calendar: CalendarPoint,
#[serde(default)]
pub world_flags: BTreeMap<String, bool>,
#[serde(default)]
pub save_profile: RuntimeSaveProfileState,
#[serde(default)]
pub world_restore: RuntimeWorldRestoreState,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub companies: Vec<RuntimeCompany>,
#[serde(default)]
pub event_runtime_records: Vec<RuntimeEventRecord>,
#[serde(default)]
pub candidate_availability: BTreeMap<String, u32>,
#[serde(default)]
pub special_conditions: BTreeMap<String, u32>,
#[serde(default)]
pub service_state: RuntimeServiceState,
}
impl RuntimeState {
pub fn validate(&self) -> Result<(), String> {
self.calendar.validate()?;
let mut seen_company_ids = BTreeSet::new();
for company in &self.companies {
if !seen_company_ids.insert(company.company_id) {
return Err(format!("duplicate company_id {}", company.company_id));
}
}
let mut seen_record_ids = BTreeSet::new();
for record in &self.event_runtime_records {
if !seen_record_ids.insert(record.record_id) {
return Err(format!("duplicate record_id {}", record.record_id));
}
2026-04-14 19:37:53 -07:00
for (effect_index, effect) in record.effects.iter().enumerate() {
validate_runtime_effect(effect, &seen_company_ids).map_err(|err| {
format!(
"event_runtime_records[record_id={}].effects[{effect_index}] {err}",
record.record_id
)
})?;
}
}
for key in self.world_flags.keys() {
if key.trim().is_empty() {
return Err("world_flags contains an empty key".to_string());
}
}
for (label, value) in [
(
"save_profile.profile_kind",
self.save_profile.profile_kind.as_deref(),
),
(
"save_profile.profile_family",
self.save_profile.profile_family.as_deref(),
),
(
"save_profile.map_path",
self.save_profile.map_path.as_deref(),
),
(
"save_profile.display_name",
self.save_profile.display_name.as_deref(),
),
] {
if value.is_some_and(|text| text.trim().is_empty()) {
return Err(format!("{label} must not be empty"));
}
}
if self.world_restore.selected_year_profile_lane.is_none()
&& (self.world_restore.campaign_scenario_enabled.is_some()
|| self.world_restore.sandbox_enabled.is_some())
{
return Err(
"world_restore.selected_year_profile_lane must be present when world restore flags are populated"
.to_string(),
);
}
if self
.world_restore
.absolute_counter_restore_kind
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err(
"world_restore.absolute_counter_restore_kind must not be empty".to_string(),
);
}
if self
.world_restore
.absolute_counter_adjustment_context
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err(
"world_restore.absolute_counter_adjustment_context must not be empty".to_string(),
);
}
for (key, value) in &self.metadata {
if key.trim().is_empty() {
return Err("metadata contains an empty key".to_string());
}
if value.trim().is_empty() {
return Err(format!("metadata[{key}] must not be empty"));
}
}
for key in self.candidate_availability.keys() {
if key.trim().is_empty() {
return Err("candidate_availability contains an empty key".to_string());
}
}
for key in self.special_conditions.keys() {
if key.trim().is_empty() {
return Err("special_conditions contains an empty key".to_string());
}
}
Ok(())
}
}
2026-04-14 19:37:53 -07:00
fn validate_runtime_effect(
effect: &RuntimeEffect,
valid_company_ids: &BTreeSet<u32>,
) -> Result<(), String> {
match effect {
RuntimeEffect::SetWorldFlag { key, .. } => {
if key.trim().is_empty() {
return Err("key must not be empty".to_string());
}
}
RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
validate_company_target(target, valid_company_ids)?;
}
RuntimeEffect::SetCandidateAvailability { name, .. } => {
if name.trim().is_empty() {
return Err("name must not be empty".to_string());
}
}
RuntimeEffect::SetSpecialCondition { label, .. } => {
if label.trim().is_empty() {
return Err("label must not be empty".to_string());
}
}
RuntimeEffect::AppendEventRecord { record } => {
validate_event_record_template(record, valid_company_ids)?;
}
RuntimeEffect::ActivateEventRecord { .. }
| RuntimeEffect::DeactivateEventRecord { .. }
| RuntimeEffect::RemoveEventRecord { .. } => {}
}
Ok(())
}
fn validate_event_record_template(
record: &RuntimeEventRecordTemplate,
valid_company_ids: &BTreeSet<u32>,
) -> Result<(), String> {
for (effect_index, effect) in record.effects.iter().enumerate() {
validate_runtime_effect(effect, valid_company_ids).map_err(|err| {
format!(
"template record_id={}.effects[{effect_index}] {err}",
record.record_id
)
})?;
}
Ok(())
}
fn validate_company_target(
target: &RuntimeCompanyTarget,
valid_company_ids: &BTreeSet<u32>,
) -> Result<(), String> {
match target {
RuntimeCompanyTarget::AllActive => Ok(()),
RuntimeCompanyTarget::Ids { ids } => {
if ids.is_empty() {
return Err("target ids must not be empty".to_string());
}
for company_id in ids {
if !valid_company_ids.contains(company_id) {
return Err(format!("target references unknown company_id {company_id}"));
}
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_duplicate_company_ids() {
let state = 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,
current_cash: 100,
debt: 0,
},
RuntimeCompany {
company_id: 1,
current_cash: 200,
debt: 0,
},
],
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
assert!(state.validate().is_err());
}
#[test]
fn rejects_partial_world_restore_without_year_lane() {
let state = 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 {
selected_year_profile_lane: None,
campaign_scenario_enabled: Some(false),
sandbox_enabled: Some(true),
seed_tuple_written_from_raw_lane: Some(true),
absolute_counter_requires_shell_context: Some(true),
absolute_counter_reconstructible_from_save: Some(false),
disable_cargo_economy_special_condition_slot: Some(30),
disable_cargo_economy_special_condition_reconstructible_from_save: Some(true),
disable_cargo_economy_special_condition_write_side_grounded: Some(true),
disable_cargo_economy_special_condition_enabled: Some(false),
use_bio_accelerator_cars_enabled: Some(false),
use_wartime_cargos_enabled: Some(false),
disable_train_crashes_enabled: Some(false),
disable_train_crashes_and_breakdowns_enabled: Some(false),
ai_ignore_territories_at_startup_enabled: Some(false),
absolute_counter_restore_kind: Some(
"mode-adjusted-selected-year-lane".to_string(),
),
absolute_counter_adjustment_context: Some(
"editor-map-mode,shell-selected-year-adjust-policy-0x9d26-0x9d28,save-special-condition-disable-cargo-economy-slot-30".to_string(),
),
},
metadata: BTreeMap::new(),
companies: Vec::new(),
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
assert!(state.validate().is_err());
}
2026-04-14 19:37:53 -07:00
#[test]
fn rejects_event_effect_targeting_unknown_company() {
let state = 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,
current_cash: 100,
debt: 0,
}],
event_runtime_records: vec![RuntimeEventRecord {
record_id: 7,
trigger_kind: 1,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AdjustCompanyCash {
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
delta: 50,
}],
}],
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
assert!(state.validate().is_err());
}
#[test]
fn rejects_template_effect_targeting_unknown_company() {
let state = 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,
current_cash: 100,
debt: 0,
}],
event_runtime_records: vec![RuntimeEventRecord {
record_id: 7,
trigger_kind: 1,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AppendEventRecord {
record: Box::new(RuntimeEventRecordTemplate {
record_id: 8,
trigger_kind: 0x0a,
active: true,
marks_collection_dirty: false,
one_shot: false,
effects: vec![RuntimeEffect::AdjustCompanyCash {
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
delta: 50,
}],
}),
}],
}],
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
};
assert!(state.validate().is_err());
}
}