Expand runtime event graph service

This commit is contained in:
Jan Petykiewicz 2026-04-14 19:37:53 -07:00
commit 6ebe5fffeb
14 changed files with 1803 additions and 254 deletions

View file

@ -4,7 +4,10 @@ use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use rrt_fixtures::{FixtureValidationReport, load_fixture_document, validate_fixture_document};
use rrt_fixtures::{
FixtureValidationReport, JsonDiffEntry, compare_expected_state_fragment, diff_json_values,
load_fixture_document, normalize_runtime_state, validate_fixture_document,
};
use rrt_model::{
BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH,
REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS,
@ -95,6 +98,10 @@ enum Command {
fixture_path: PathBuf,
output_path: PathBuf,
},
RuntimeDiffState {
left_path: PathBuf,
right_path: PathBuf,
},
RuntimeSummarizeState {
snapshot_path: PathBuf,
},
@ -195,6 +202,8 @@ struct RuntimeFixtureSummaryReport {
final_summary: RuntimeSummary,
expected_summary_matches: bool,
expected_summary_mismatches: Vec<String>,
expected_state_fragment_matches: bool,
expected_state_fragment_mismatches: Vec<String>,
}
#[derive(Debug, Serialize)]
@ -203,6 +212,13 @@ struct RuntimeStateSummaryReport {
summary: RuntimeSummary,
}
#[derive(Debug, Serialize)]
struct RuntimeStateDiffReport {
matches: bool,
difference_count: usize,
differences: Vec<JsonDiffEntry>,
}
#[derive(Debug, Serialize)]
struct RuntimeSmpInspectionOutput {
path: String,
@ -724,6 +740,12 @@ fn real_main() -> Result<(), Box<dyn std::error::Error>> {
} => {
run_runtime_export_fixture_state(&fixture_path, &output_path)?;
}
Command::RuntimeDiffState {
left_path,
right_path,
} => {
run_runtime_diff_state(&left_path, &right_path)?;
}
Command::RuntimeSummarizeState { snapshot_path } => {
run_runtime_summarize_state(&snapshot_path)?;
}
@ -859,6 +881,14 @@ fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
output_path: PathBuf::from(output_path),
})
}
[command, subcommand, left_path, right_path]
if command == "runtime" && subcommand == "diff-state" =>
{
Ok(Command::RuntimeDiffState {
left_path: PathBuf::from(left_path),
right_path: PathBuf::from(right_path),
})
}
[command, subcommand, path] if command == "runtime" && subcommand == "summarize-state" => {
Ok(Command::RuntimeSummarizeState {
snapshot_path: PathBuf::from(path),
@ -1039,7 +1069,7 @@ fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
})
}
_ => Err(
"usage: rrt-cli [validate [repo-root] | finance eval <snapshot.json> | finance diff <left.json> <right.json> | runtime validate-fixture <fixture.json> | runtime summarize-fixture <fixture.json> | runtime export-fixture-state <fixture.json> <snapshot.json> | runtime summarize-state <snapshot.json> | runtime import-state <input.json> <snapshot.json> | runtime inspect-smp <file.smp> | runtime summarize-save-load <file.smp> | runtime load-save-slice <file.smp> | runtime import-save-state <file.smp> <snapshot.json> | runtime inspect-pk4 <file.pk4> | runtime inspect-win <file.win> | runtime extract-pk4-entry <file.pk4> <entry-name> <output-path> | runtime inspect-campaign-exe <RT3.exe> | runtime compare-classic-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-105-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-candidate-table <file1> <file2> [fileN...] | runtime compare-recipe-book-lines <file1> <file2> [fileN...] | runtime compare-setup-payload-core <file1> <file2> [fileN...] | runtime compare-setup-launch-payload <file1> <file2> [fileN...] | runtime compare-post-special-conditions-scalars <file1> <file2> [fileN...] | runtime scan-candidate-table-headers <root-dir> | runtime scan-special-conditions <root-dir> | runtime scan-aligned-runtime-rule-band <root-dir> | runtime scan-post-special-conditions-scalars <root-dir> | runtime scan-post-special-conditions-tail <root-dir> | runtime scan-recipe-book-lines <root-dir> | runtime export-profile-block <save.gms> <profile.json>]"
"usage: rrt-cli [validate [repo-root] | finance eval <snapshot.json> | finance diff <left.json> <right.json> | runtime validate-fixture <fixture.json> | runtime summarize-fixture <fixture.json> | runtime export-fixture-state <fixture.json> <snapshot.json> | runtime diff-state <left.json> <right.json> | runtime summarize-state <snapshot.json> | runtime import-state <input.json> <snapshot.json> | runtime inspect-smp <file.smp> | runtime summarize-save-load <file.smp> | runtime load-save-slice <file.smp> | runtime import-save-state <file.smp> <snapshot.json> | runtime inspect-pk4 <file.pk4> | runtime inspect-win <file.win> | runtime extract-pk4-entry <file.pk4> <entry-name> <output-path> | runtime inspect-campaign-exe <RT3.exe> | runtime compare-classic-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-105-profile <save1.gms> <save2.gms> [saveN.gms...] | runtime compare-candidate-table <file1> <file2> [fileN...] | runtime compare-recipe-book-lines <file1> <file2> [fileN...] | runtime compare-setup-payload-core <file1> <file2> [fileN...] | runtime compare-setup-launch-payload <file1> <file2> [fileN...] | runtime compare-post-special-conditions-scalars <file1> <file2> [fileN...] | runtime scan-candidate-table-headers <root-dir> | runtime scan-special-conditions <root-dir> | runtime scan-aligned-runtime-rule-band <root-dir> | runtime scan-post-special-conditions-scalars <root-dir> | runtime scan-post-special-conditions-tail <root-dir> | runtime scan-recipe-book-lines <root-dir> | runtime export-profile-block <save.gms> <profile.json>]"
.into(),
),
}
@ -1083,20 +1113,31 @@ fn run_runtime_summarize_fixture(fixture_path: &Path) -> Result<(), Box<dyn std:
}
let final_summary = RuntimeSummary::from_state(&state);
let mismatches = fixture.expected_summary.compare(&final_summary);
let expected_summary_mismatches = fixture.expected_summary.compare(&final_summary);
let expected_state_fragment_mismatches = match &fixture.expected_state_fragment {
Some(expected_fragment) => {
let normalized_state = normalize_runtime_state(&state)?;
compare_expected_state_fragment(expected_fragment, &normalized_state)
}
None => Vec::new(),
};
let report = RuntimeFixtureSummaryReport {
fixture_id: fixture.fixture_id,
command_count: fixture.commands.len(),
expected_summary_matches: mismatches.is_empty(),
expected_summary_mismatches: mismatches.clone(),
expected_summary_matches: expected_summary_mismatches.is_empty(),
expected_summary_mismatches: expected_summary_mismatches.clone(),
expected_state_fragment_matches: expected_state_fragment_mismatches.is_empty(),
expected_state_fragment_mismatches: expected_state_fragment_mismatches.clone(),
final_summary,
};
println!("{}", serde_json::to_string_pretty(&report)?);
if !mismatches.is_empty() {
if !expected_summary_mismatches.is_empty() || !expected_state_fragment_mismatches.is_empty() {
let mut mismatch_messages = expected_summary_mismatches;
mismatch_messages.extend(expected_state_fragment_mismatches);
return Err(format!(
"fixture summary mismatched expected output: {}",
mismatches.join("; ")
mismatch_messages.join("; ")
)
.into());
}
@ -1159,6 +1200,33 @@ fn run_runtime_summarize_state(snapshot_path: &Path) -> Result<(), Box<dyn std::
Ok(())
}
fn run_runtime_diff_state(
left_path: &Path,
right_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let left = load_normalized_runtime_state(left_path)?;
let right = load_normalized_runtime_state(right_path)?;
let differences = diff_json_values(&left, &right);
let report = RuntimeStateDiffReport {
matches: differences.is_empty(),
difference_count: differences.len(),
differences,
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn load_normalized_runtime_state(path: &Path) -> Result<Value, Box<dyn std::error::Error>> {
if let Ok(snapshot) = load_runtime_snapshot_document(path) {
validate_runtime_snapshot_document(&snapshot)
.map_err(|err| format!("invalid runtime snapshot: {err}"))?;
return normalize_runtime_state(&snapshot.state);
}
let import = load_runtime_state_import(path)?;
normalize_runtime_state(&import.state)
}
fn run_runtime_import_state(
input_path: &Path,
output_path: &Path,
@ -3834,6 +3902,14 @@ mod tests {
"company_count": 0,
"event_runtime_record_count": 0,
"total_company_cash": 0
},
"expected_state_fragment": {
"calendar": {
"tick_slot": 3
},
"world_flags": {
"sandbox": false
}
}
});
let path = write_temp_json("runtime-fixture", &fixture);
@ -3938,6 +4014,121 @@ mod tests {
let _ = fs::remove_file(output_path);
}
#[test]
fn diffs_runtime_states_recursively() {
let left = serde_json::json!({
"format_version": 1,
"snapshot_id": "left",
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 1
},
"world_flags": {
"sandbox": false
},
"companies": []
}
});
let right = serde_json::json!({
"format_version": 1,
"snapshot_id": "right",
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 2
},
"world_flags": {
"sandbox": true
},
"companies": []
}
});
let left_path = write_temp_json("runtime-diff-left", &left);
let right_path = write_temp_json("runtime-diff-right", &right);
run_runtime_diff_state(&left_path, &right_path).expect("runtime diff should succeed");
let _ = fs::remove_file(left_path);
let _ = fs::remove_file(right_path);
}
#[test]
fn diffs_runtime_states_with_event_record_additions_and_removals() {
let left = serde_json::json!({
"format_version": 1,
"snapshot_id": "left-events",
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 1
},
"world_flags": {
"sandbox": false
},
"companies": [],
"event_runtime_records": [
{
"record_id": 1,
"trigger_kind": 7,
"active": true
},
{
"record_id": 2,
"trigger_kind": 7,
"active": false
}
]
}
});
let right = serde_json::json!({
"format_version": 1,
"snapshot_id": "right-events",
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 1
},
"world_flags": {
"sandbox": false
},
"companies": [],
"event_runtime_records": [
{
"record_id": 1,
"trigger_kind": 7,
"active": true
}
]
}
});
let left_path = write_temp_json("runtime-diff-events-left", &left);
let right_path = write_temp_json("runtime-diff-events-right", &right);
let left_state =
load_normalized_runtime_state(&left_path).expect("left runtime state should load");
let right_state =
load_normalized_runtime_state(&right_path).expect("right runtime state should load");
let differences = diff_json_values(&left_state, &right_state);
assert!(
differences
.iter()
.any(|entry| entry.path == "$.event_runtime_records[1]")
);
let _ = fs::remove_file(left_path);
let _ = fs::remove_file(right_path);
}
#[test]
fn diffs_classic_profile_samples_across_multiple_files() {
let sample_a = RuntimeClassicProfileSample {

View file

@ -8,5 +8,6 @@ pub use load::{load_fixture_document, load_fixture_document_from_str};
pub use normalize::normalize_runtime_state;
pub use schema::{
ExpectedRuntimeSummary, FIXTURE_FORMAT_VERSION, FixtureDocument, FixtureSource,
FixtureStateOrigin, FixtureValidationReport, RawFixtureDocument, validate_fixture_document,
FixtureStateOrigin, FixtureValidationReport, RawFixtureDocument,
compare_expected_state_fragment, validate_fixture_document,
};

View file

@ -64,6 +64,7 @@ fn resolve_raw_fixture_document(
state_origin,
commands: raw.commands,
expected_summary: raw.expected_summary,
expected_state_fragment: raw.expected_state_fragment,
})
}

View file

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use rrt_runtime::{RuntimeState, RuntimeSummary, StepCommand};
@ -461,6 +462,8 @@ pub struct FixtureDocument {
pub commands: Vec<StepCommand>,
#[serde(default)]
pub expected_summary: ExpectedRuntimeSummary,
#[serde(default)]
pub expected_state_fragment: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -483,6 +486,8 @@ pub struct RawFixtureDocument {
pub commands: Vec<StepCommand>,
#[serde(default)]
pub expected_summary: ExpectedRuntimeSummary,
#[serde(default)]
pub expected_state_fragment: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -493,6 +498,54 @@ pub struct FixtureValidationReport {
pub issues: Vec<String>,
}
pub fn compare_expected_state_fragment(expected: &Value, actual: &Value) -> Vec<String> {
let mut mismatches = Vec::new();
compare_expected_state_fragment_at_path("$", expected, actual, &mut mismatches);
mismatches
}
fn compare_expected_state_fragment_at_path(
path: &str,
expected: &Value,
actual: &Value,
mismatches: &mut Vec<String>,
) {
match (expected, actual) {
(Value::Object(expected_map), Value::Object(actual_map)) => {
for (key, expected_value) in expected_map {
let next_path = format!("{path}.{key}");
match actual_map.get(key) {
Some(actual_value) => compare_expected_state_fragment_at_path(
&next_path,
expected_value,
actual_value,
mismatches,
),
None => mismatches.push(format!("{next_path} missing in actual state")),
}
}
}
(Value::Array(expected_items), Value::Array(actual_items)) => {
for (index, expected_item) in expected_items.iter().enumerate() {
let next_path = format!("{path}[{index}]");
match actual_items.get(index) {
Some(actual_item) => compare_expected_state_fragment_at_path(
&next_path,
expected_item,
actual_item,
mismatches,
),
None => mismatches.push(format!("{next_path} missing in actual state")),
}
}
}
_ if expected != actual => mismatches.push(format!(
"{path} mismatch: expected {expected:?}, got {actual:?}"
)),
_ => {}
}
}
pub fn validate_fixture_document(document: &FixtureDocument) -> FixtureValidationReport {
let mut issues = Vec::new();
@ -609,4 +662,36 @@ mod tests {
assert_eq!(mismatches.len(), 1);
assert!(mismatches[0].contains("calendar mismatch"));
}
#[test]
fn compares_expected_state_fragment_recursively() {
let expected = serde_json::json!({
"world_flags": {
"sandbox": false
},
"companies": [
{
"company_id": 1
}
]
});
let actual = serde_json::json!({
"world_flags": {
"sandbox": false,
"runtime.effect_fired": true
},
"companies": [
{
"company_id": 1,
"current_cash": 250000
}
]
});
let mismatches = compare_expected_state_fragment(&expected, &actual);
assert!(
mismatches.is_empty(),
"unexpected mismatches: {mismatches:?}"
);
}
}

View file

@ -29,7 +29,8 @@ pub use pk4::{
extract_pk4_entry_bytes, extract_pk4_entry_file, inspect_pk4_bytes, inspect_pk4_file,
};
pub use runtime::{
RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeWorldRestoreState,
};
pub use smp::{

View file

@ -11,6 +11,63 @@ pub struct RuntimeCompany {
pub debt: u64,
}
#[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,
@ -20,6 +77,27 @@ pub struct RuntimeEventRecord {
pub service_count: u32,
#[serde(default)]
pub marks_collection_dirty: bool,
#[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)]
@ -131,6 +209,14 @@ impl RuntimeState {
if !seen_record_ids.insert(record.record_id) {
return Err(format!("duplicate record_id {}", record.record_id));
}
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() {
@ -216,6 +302,77 @@ impl RuntimeState {
}
}
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::*;
@ -298,4 +455,91 @@ mod tests {
assert!(state.validate().is_err());
}
#[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());
}
}

View file

@ -1,6 +1,11 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::{RuntimeState, RuntimeSummary, calendar::BoundaryEventKind};
use crate::{
RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate, RuntimeState, RuntimeSummary,
calendar::BoundaryEventKind,
};
const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4];
@ -39,6 +44,12 @@ 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 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,
}
@ -51,6 +62,23 @@ pub struct StepResult {
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>,
}
pub fn execute_step_command(
state: &mut RuntimeState,
command: &StepCommand,
@ -67,11 +95,11 @@ pub fn execute_step_command(
}
StepCommand::StepCount { steps } => step_count(state, *steps, &mut boundary_events),
StepCommand::ServiceTriggerKind { trigger_kind } => {
service_trigger_kind(state, *trigger_kind, &mut service_events);
service_trigger_kind(state, *trigger_kind, &mut service_events)?;
0
}
StepCommand::ServicePeriodicBoundary => {
service_periodic_boundary(state, &mut service_events);
service_periodic_boundary(state, &mut service_events)?;
0
}
};
@ -137,20 +165,44 @@ fn boundary_kind_label(boundary: BoundaryEventKind) -> &'static str {
}
}
fn service_periodic_boundary(state: &mut RuntimeState, service_events: &mut Vec<ServiceEvent>) {
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);
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 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
@ -159,27 +211,237 @@ fn service_trigger_kind(
.entry(trigger_kind)
.or_insert(0) += 1;
for record in &mut state.event_runtime_records {
if record.active && record.trigger_kind == trigger_kind {
for index in eligible_indices {
let (record_id, record_effects, record_marks_collection_dirty, record_one_shot) = {
let record = &state.event_runtime_records[index];
(
record.record_id,
record.effects.clone(),
record.marks_collection_dirty,
record.one_shot,
)
};
let effect_summary = apply_runtime_effects(
state,
&record_effects,
&mut mutated_company_ids,
&mut staged_event_graph_mutations,
)?;
applied_effect_count += effect_summary.applied_effect_count;
appended_record_ids.extend(effect_summary.appended_record_ids);
activated_record_ids.extend(effect_summary.activated_record_ids);
deactivated_record_ids.extend(effect_summary.deactivated_record_ids);
removed_record_ids.extend(effect_summary.removed_record_ids);
{
let record = &mut state.event_runtime_records[index];
record.service_count += 1;
serviced_record_ids.push(record.record_id);
state.service_state.total_event_record_services += 1;
if trigger_kind != 0x0a && record.marks_collection_dirty {
dirty_rerun = true;
if record_one_shot {
record.has_fired = true;
}
}
serviced_record_ids.push(record_id);
state.service_state.total_event_record_services += 1;
if trigger_kind != 0x0a && record_marks_collection_dirty {
dirty_rerun = true;
}
}
commit_staged_event_graph_mutations(state, &staged_event_graph_mutations)?;
service_events.push(ServiceEvent {
kind: "trigger_dispatch".to_string(),
trigger_kind: Some(trigger_kind),
serviced_record_ids,
applied_effect_count,
mutated_company_ids: mutated_company_ids.into_iter().collect(),
appended_record_ids,
activated_record_ids,
deactivated_record_ids,
removed_record_ids,
dirty_rerun,
});
if dirty_rerun {
state.service_state.dirty_rerun_count += 1;
service_trigger_kind(state, 0x0a, service_events);
service_trigger_kind(state, 0x0a, service_events)?;
}
Ok(())
}
fn apply_runtime_effects(
state: &mut RuntimeState,
effects: &[RuntimeEffect],
mutated_company_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::AdjustCompanyCash { target, delta } => {
let company_ids = resolve_company_target_ids(state, target)?;
for company_id in company_ids {
let company = state
.companies
.iter_mut()
.find(|company| company.company_id == company_id)
.ok_or_else(|| {
format!("missing company_id {company_id} while applying cash effect")
})?;
company.current_cash =
company.current_cash.checked_add(*delta).ok_or_else(|| {
format!("company_id {company_id} cash adjustment overflow")
})?;
mutated_company_ids.insert(company_id);
}
}
RuntimeEffect::AdjustCompanyDebt { target, delta } => {
let company_ids = resolve_company_target_ids(state, target)?;
for company_id in company_ids {
let company = state
.companies
.iter_mut()
.find(|company| company.company_id == company_id)
.ok_or_else(|| {
format!("missing company_id {company_id} while applying debt effect")
})?;
company.debt = apply_u64_delta(company.debt, *delta, company_id)?;
mutated_company_ids.insert(company_id);
}
}
RuntimeEffect::SetCandidateAvailability { name, value } => {
state.candidate_availability.insert(name.clone(), *value);
}
RuntimeEffect::SetSpecialCondition { label, value } => {
state.special_conditions.insert(label.clone(), *value);
}
RuntimeEffect::AppendEventRecord { record } => {
staged_event_graph_mutations.push(EventGraphMutation::Append((**record).clone()));
summary.appended_record_ids.push(record.record_id);
}
RuntimeEffect::ActivateEventRecord { record_id } => {
staged_event_graph_mutations.push(EventGraphMutation::Activate {
record_id: *record_id,
});
summary.activated_record_ids.push(*record_id);
}
RuntimeEffect::DeactivateEventRecord { record_id } => {
staged_event_graph_mutations.push(EventGraphMutation::Deactivate {
record_id: *record_id,
});
summary.deactivated_record_ids.push(*record_id);
}
RuntimeEffect::RemoveEventRecord { record_id } => {
staged_event_graph_mutations.push(EventGraphMutation::Remove {
record_id: *record_id,
});
summary.removed_record_ids.push(*record_id);
}
}
summary.applied_effect_count += 1;
}
Ok(summary)
}
fn commit_staged_event_graph_mutations(
state: &mut RuntimeState,
staged_event_graph_mutations: &[EventGraphMutation],
) -> Result<(), String> {
for mutation in staged_event_graph_mutations {
match mutation {
EventGraphMutation::Append(record) => {
if state
.event_runtime_records
.iter()
.any(|existing| existing.record_id == record.record_id)
{
return Err(format!(
"cannot append duplicate event record_id {}",
record.record_id
));
}
state
.event_runtime_records
.push(record.clone().into_runtime_record());
}
EventGraphMutation::Activate { record_id } => {
let record = state
.event_runtime_records
.iter_mut()
.find(|record| record.record_id == *record_id)
.ok_or_else(|| {
format!("cannot activate missing event record_id {record_id}")
})?;
record.active = true;
}
EventGraphMutation::Deactivate { record_id } => {
let record = state
.event_runtime_records
.iter_mut()
.find(|record| record.record_id == *record_id)
.ok_or_else(|| {
format!("cannot deactivate missing event record_id {record_id}")
})?;
record.active = false;
}
EventGraphMutation::Remove { record_id } => {
let index = state
.event_runtime_records
.iter()
.position(|record| record.record_id == *record_id)
.ok_or_else(|| format!("cannot remove missing event record_id {record_id}"))?;
state.event_runtime_records.remove(index);
}
}
}
state.validate()
}
fn resolve_company_target_ids(
state: &RuntimeState,
target: &RuntimeCompanyTarget,
) -> Result<Vec<u32>, String> {
match target {
RuntimeCompanyTarget::AllActive => Ok(state
.companies
.iter()
.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())
}
}
}
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"))
}
}
@ -189,8 +451,9 @@ mod tests {
use super::*;
use crate::{
CalendarPoint, RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState,
RuntimeServiceState, RuntimeWorldRestoreState,
CalendarPoint, RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState,
RuntimeWorldRestoreState,
};
fn state() -> RuntimeState {
@ -267,6 +530,12 @@ mod tests {
active: true,
service_count: 0,
marks_collection_dirty: true,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::SetWorldFlag {
key: "runtime.effect_fired".to_string(),
value: true,
}],
},
RuntimeEventRecord {
record_id: 2,
@ -274,6 +543,12 @@ mod tests {
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AdjustCompanyCash {
target: RuntimeCompanyTarget::AllActive,
delta: 5,
}],
},
RuntimeEventRecord {
record_id: 3,
@ -281,6 +556,12 @@ mod tests {
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::SetSpecialCondition {
label: "Dirty rerun fired".to_string(),
value: 1,
}],
},
],
..state()
@ -296,6 +577,9 @@ mod tests {
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)
@ -308,5 +592,444 @@ mod tests {
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,
current_cash: 10,
debt: 5,
},
RuntimeCompany {
company_id: 2,
current_cash: 20,
debt: 8,
},
],
event_runtime_records: vec![RuntimeEventRecord {
record_id: 10,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![
RuntimeEffect::AdjustCompanyCash {
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
delta: 4,
},
RuntimeEffect::AdjustCompanyDebt {
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
delta: -3,
},
],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("targeted company effects should succeed");
assert_eq!(state.companies[0].current_cash, 10);
assert_eq!(state.companies[1].current_cash, 24);
assert_eq!(state.companies[1].debt, 5);
assert_eq!(result.service_events[0].applied_effect_count, 2);
assert_eq!(result.service_events[0].mutated_company_ids, vec![2]);
}
#[test]
fn one_shot_record_only_fires_once() {
let mut state = RuntimeState {
event_runtime_records: vec![RuntimeEventRecord {
record_id: 20,
trigger_kind: 2,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: true,
has_fired: false,
effects: vec![RuntimeEffect::SetWorldFlag {
key: "one_shot".to_string(),
value: true,
}],
}],
..state()
};
execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 2 },
)
.expect("first one-shot service should succeed");
let second = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 2 },
)
.expect("second one-shot service should succeed");
assert_eq!(state.event_runtime_records[0].service_count, 1);
assert!(state.event_runtime_records[0].has_fired);
assert_eq!(
second.service_events[0].serviced_record_ids,
Vec::<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,
current_cash: 10,
debt: 2,
}],
event_runtime_records: vec![RuntimeEventRecord {
record_id: 30,
trigger_kind: 3,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AdjustCompanyDebt {
target: RuntimeCompanyTarget::AllActive,
delta: -3,
}],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 3 },
);
assert!(result.is_err());
}
#[test]
fn appended_record_waits_until_later_pass_without_dirty_rerun() {
let mut state = RuntimeState {
event_runtime_records: vec![RuntimeEventRecord {
record_id: 40,
trigger_kind: 5,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: true,
has_fired: false,
effects: vec![RuntimeEffect::AppendEventRecord {
record: Box::new(RuntimeEventRecordTemplate {
record_id: 41,
trigger_kind: 5,
active: true,
marks_collection_dirty: false,
one_shot: false,
effects: vec![RuntimeEffect::SetWorldFlag {
key: "follow_on_later_pass".to_string(),
value: true,
}],
}),
}],
}],
..state()
};
let first = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 5 },
)
.expect("first pass should succeed");
assert_eq!(first.service_events.len(), 1);
assert_eq!(first.service_events[0].serviced_record_ids, vec![40]);
assert_eq!(first.service_events[0].appended_record_ids, vec![41]);
assert_eq!(state.world_flags.get("follow_on_later_pass"), None);
assert_eq!(state.event_runtime_records.len(), 2);
assert_eq!(state.event_runtime_records[1].service_count, 0);
let second = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 5 },
)
.expect("second pass should succeed");
assert_eq!(second.service_events[0].serviced_record_ids, vec![41]);
assert_eq!(state.world_flags.get("follow_on_later_pass"), Some(&true));
assert!(state.event_runtime_records[0].has_fired);
assert_eq!(state.event_runtime_records[1].service_count, 1);
}
#[test]
fn appended_record_runs_in_dirty_rerun_after_commit() {
let mut state = RuntimeState {
event_runtime_records: vec![RuntimeEventRecord {
record_id: 50,
trigger_kind: 1,
active: true,
service_count: 0,
marks_collection_dirty: true,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AppendEventRecord {
record: Box::new(RuntimeEventRecordTemplate {
record_id: 51,
trigger_kind: 0x0a,
active: true,
marks_collection_dirty: false,
one_shot: false,
effects: vec![RuntimeEffect::SetWorldFlag {
key: "dirty_rerun_follow_on".to_string(),
value: true,
}],
}),
}],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 1 },
)
.expect("dirty rerun with follow-on should succeed");
assert_eq!(result.service_events.len(), 2);
assert_eq!(result.service_events[0].serviced_record_ids, vec![50]);
assert_eq!(result.service_events[0].appended_record_ids, vec![51]);
assert_eq!(result.service_events[1].trigger_kind, Some(0x0a));
assert_eq!(result.service_events[1].serviced_record_ids, vec![51]);
assert_eq!(state.service_state.dirty_rerun_count, 1);
assert_eq!(state.event_runtime_records.len(), 2);
assert_eq!(state.event_runtime_records[1].service_count, 1);
assert_eq!(state.world_flags.get("dirty_rerun_follow_on"), Some(&true));
}
#[test]
fn lifecycle_mutations_commit_between_passes() {
let mut state = RuntimeState {
event_runtime_records: vec![
RuntimeEventRecord {
record_id: 60,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: true,
has_fired: false,
effects: vec![
RuntimeEffect::AppendEventRecord {
record: Box::new(RuntimeEventRecordTemplate {
record_id: 64,
trigger_kind: 7,
active: true,
marks_collection_dirty: false,
one_shot: false,
effects: vec![RuntimeEffect::SetCandidateAvailability {
name: "Appended Industry".to_string(),
value: 1,
}],
}),
},
RuntimeEffect::DeactivateEventRecord { record_id: 61 },
RuntimeEffect::ActivateEventRecord { record_id: 62 },
RuntimeEffect::RemoveEventRecord { record_id: 63 },
],
},
RuntimeEventRecord {
record_id: 61,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::SetWorldFlag {
key: "deactivated_after_first_pass".to_string(),
value: true,
}],
},
RuntimeEventRecord {
record_id: 62,
trigger_kind: 7,
active: false,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::SetSpecialCondition {
label: "Activated On Second Pass".to_string(),
value: 1,
}],
},
RuntimeEventRecord {
record_id: 63,
trigger_kind: 7,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::SetWorldFlag {
key: "removed_after_first_pass".to_string(),
value: true,
}],
},
],
..state()
};
let first = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("first lifecycle pass should succeed");
assert_eq!(
first.service_events[0].serviced_record_ids,
vec![60, 61, 63]
);
assert_eq!(first.service_events[0].appended_record_ids, vec![64]);
assert_eq!(first.service_events[0].activated_record_ids, vec![62]);
assert_eq!(first.service_events[0].deactivated_record_ids, vec![61]);
assert_eq!(first.service_events[0].removed_record_ids, vec![63]);
assert_eq!(
state
.event_runtime_records
.iter()
.map(|record| (record.record_id, record.active))
.collect::<Vec<_>>(),
vec![(60, true), (61, false), (62, true), (64, true)]
);
let second = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("second lifecycle pass should succeed");
assert_eq!(second.service_events[0].serviced_record_ids, vec![62, 64]);
assert_eq!(
state.special_conditions.get("Activated On Second Pass"),
Some(&1)
);
assert_eq!(
state.candidate_availability.get("Appended Industry"),
Some(&1)
);
assert_eq!(
state.world_flags.get("deactivated_after_first_pass"),
Some(&true)
);
assert_eq!(
state.world_flags.get("removed_after_first_pass"),
Some(&true)
);
}
#[test]
fn rejects_duplicate_appended_record_id() {
let mut state = RuntimeState {
event_runtime_records: vec![
RuntimeEventRecord {
record_id: 70,
trigger_kind: 4,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AppendEventRecord {
record: Box::new(RuntimeEventRecordTemplate {
record_id: 71,
trigger_kind: 4,
active: true,
marks_collection_dirty: false,
one_shot: false,
effects: Vec::new(),
}),
}],
},
RuntimeEventRecord {
record_id: 71,
trigger_kind: 4,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: Vec::new(),
},
],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 4 },
);
assert!(result.is_err());
}
#[test]
fn rejects_missing_lifecycle_mutation_target() {
let mut state = RuntimeState {
event_runtime_records: vec![RuntimeEventRecord {
record_id: 80,
trigger_kind: 6,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::ActivateEventRecord { record_id: 999 }],
}],
..state()
};
let result = execute_step_command(
&mut state,
&StepCommand::ServiceTriggerKind { trigger_kind: 6 },
);
assert!(result.is_err());
}
}