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

@ -8,8 +8,10 @@ enough to choose good rewrite targets. As we go, we document evidence, keep a cu
and stand up Rust tooling that can validate artifacts and later host replacement code. and stand up Rust tooling that can validate artifacts and later host replacement code.
The long-term direction is still a DLL we can inject into the original executable, patching in The long-term direction is still a DLL we can inject into the original executable, patching in
individual functions as we build them out. The current implementation milestone is smaller: a individual functions as we build them out. The active implementation milestone is now a headless
minimal PE32 Rust hook that can load into RT3 under Wine without changing behavior. runtime rehost layer that can execute deterministic world work, compare normalized state, and grow
subsystem breadth without depending on the shell or presentation path. The PE32 hook remains useful
as capture and integration tooling, but it is no longer the main execution milestone.
## Project Docs ## Project Docs
@ -29,10 +31,17 @@ The first committed exports for the canonical 1.06 executable live in `artifacts
The Rust workspace is split into focused crates: The Rust workspace is split into focused crates:
- `rrt-model`: shared types for addresses, function-map rows, and control-loop concepts - `rrt-model`: shared types for addresses, function-map rows, and control-loop concepts
- `rrt-cli`: validation and repo-health checks for the reverse-engineering baseline - `rrt-runtime`: headless runtime state, stepping, normalized event service, and persistence-facing
- `rrt-hook`: minimal Windows DLL scaffold that currently builds a `dinput8.dll` proxy for runtime types
low-risk in-process loading experiments under Wine - `rrt-fixtures`: fixture schemas, loading, normalization, and diff helpers for rehost validation
- `rrt-cli`: validation, runtime fixture execution, state-diff tools, and repo-health checks
- `rrt-hook`: minimal Windows DLL scaffold for low-risk in-process loading, capture, and later
integration experiments under Wine
For the current runtime smoke test, run `tools/run_hook_smoke_test.sh`. It builds the PE32 proxy, For the current headless runtime smoke path, use `cargo run -p rrt-cli -- runtime summarize-fixture
fixtures/runtime/minimal-world-step-smoke.json` or one of the broader runtime fixtures under
`fixtures/runtime/`.
For the current hook smoke test, run `tools/run_hook_smoke_test.sh`. It builds the PE32 proxy,
copies it into the local RT3 install, launches the game briefly under Wine with copies it into the local RT3 install, launches the game briefly under Wine with
`WINEDLLOVERRIDES=dinput8=n,b`, and expects `rrt_hook_attach.log` to appear. `WINEDLLOVERRIDES=dinput8=n,b`, and expects `rrt_hook_attach.log` to appear.

View file

@ -4,7 +4,10 @@ use std::fs;
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; 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::{ use rrt_model::{
BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH, BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH,
REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS, REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS,
@ -95,6 +98,10 @@ enum Command {
fixture_path: PathBuf, fixture_path: PathBuf,
output_path: PathBuf, output_path: PathBuf,
}, },
RuntimeDiffState {
left_path: PathBuf,
right_path: PathBuf,
},
RuntimeSummarizeState { RuntimeSummarizeState {
snapshot_path: PathBuf, snapshot_path: PathBuf,
}, },
@ -195,6 +202,8 @@ struct RuntimeFixtureSummaryReport {
final_summary: RuntimeSummary, final_summary: RuntimeSummary,
expected_summary_matches: bool, expected_summary_matches: bool,
expected_summary_mismatches: Vec<String>, expected_summary_mismatches: Vec<String>,
expected_state_fragment_matches: bool,
expected_state_fragment_mismatches: Vec<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -203,6 +212,13 @@ struct RuntimeStateSummaryReport {
summary: RuntimeSummary, summary: RuntimeSummary,
} }
#[derive(Debug, Serialize)]
struct RuntimeStateDiffReport {
matches: bool,
difference_count: usize,
differences: Vec<JsonDiffEntry>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct RuntimeSmpInspectionOutput { struct RuntimeSmpInspectionOutput {
path: String, path: String,
@ -724,6 +740,12 @@ fn real_main() -> Result<(), Box<dyn std::error::Error>> {
} => { } => {
run_runtime_export_fixture_state(&fixture_path, &output_path)?; 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 } => { Command::RuntimeSummarizeState { snapshot_path } => {
run_runtime_summarize_state(&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), 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" => { [command, subcommand, path] if command == "runtime" && subcommand == "summarize-state" => {
Ok(Command::RuntimeSummarizeState { Ok(Command::RuntimeSummarizeState {
snapshot_path: PathBuf::from(path), snapshot_path: PathBuf::from(path),
@ -1039,7 +1069,7 @@ fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
}) })
} }
_ => Err( _ => 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(), .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 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 { let report = RuntimeFixtureSummaryReport {
fixture_id: fixture.fixture_id, fixture_id: fixture.fixture_id,
command_count: fixture.commands.len(), command_count: fixture.commands.len(),
expected_summary_matches: mismatches.is_empty(), expected_summary_matches: expected_summary_mismatches.is_empty(),
expected_summary_mismatches: mismatches.clone(), 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, final_summary,
}; };
println!("{}", serde_json::to_string_pretty(&report)?); 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!( return Err(format!(
"fixture summary mismatched expected output: {}", "fixture summary mismatched expected output: {}",
mismatches.join("; ") mismatch_messages.join("; ")
) )
.into()); .into());
} }
@ -1159,6 +1200,33 @@ fn run_runtime_summarize_state(snapshot_path: &Path) -> Result<(), Box<dyn std::
Ok(()) 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( fn run_runtime_import_state(
input_path: &Path, input_path: &Path,
output_path: &Path, output_path: &Path,
@ -3834,6 +3902,14 @@ mod tests {
"company_count": 0, "company_count": 0,
"event_runtime_record_count": 0, "event_runtime_record_count": 0,
"total_company_cash": 0 "total_company_cash": 0
},
"expected_state_fragment": {
"calendar": {
"tick_slot": 3
},
"world_flags": {
"sandbox": false
}
} }
}); });
let path = write_temp_json("runtime-fixture", &fixture); let path = write_temp_json("runtime-fixture", &fixture);
@ -3938,6 +4014,121 @@ mod tests {
let _ = fs::remove_file(output_path); 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] #[test]
fn diffs_classic_profile_samples_across_multiple_files() { fn diffs_classic_profile_samples_across_multiple_files() {
let sample_a = RuntimeClassicProfileSample { 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 normalize::normalize_runtime_state;
pub use schema::{ pub use schema::{
ExpectedRuntimeSummary, FIXTURE_FORMAT_VERSION, FixtureDocument, FixtureSource, 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, state_origin,
commands: raw.commands, commands: raw.commands,
expected_summary: raw.expected_summary, expected_summary: raw.expected_summary,
expected_state_fragment: raw.expected_state_fragment,
}) })
} }

View file

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use rrt_runtime::{RuntimeState, RuntimeSummary, StepCommand}; use rrt_runtime::{RuntimeState, RuntimeSummary, StepCommand};
@ -461,6 +462,8 @@ pub struct FixtureDocument {
pub commands: Vec<StepCommand>, pub commands: Vec<StepCommand>,
#[serde(default)] #[serde(default)]
pub expected_summary: ExpectedRuntimeSummary, pub expected_summary: ExpectedRuntimeSummary,
#[serde(default)]
pub expected_state_fragment: Option<Value>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -483,6 +486,8 @@ pub struct RawFixtureDocument {
pub commands: Vec<StepCommand>, pub commands: Vec<StepCommand>,
#[serde(default)] #[serde(default)]
pub expected_summary: ExpectedRuntimeSummary, pub expected_summary: ExpectedRuntimeSummary,
#[serde(default)]
pub expected_state_fragment: Option<Value>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -493,6 +498,54 @@ pub struct FixtureValidationReport {
pub issues: Vec<String>, 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 { pub fn validate_fixture_document(document: &FixtureDocument) -> FixtureValidationReport {
let mut issues = Vec::new(); let mut issues = Vec::new();
@ -609,4 +662,36 @@ mod tests {
assert_eq!(mismatches.len(), 1); assert_eq!(mismatches.len(), 1);
assert!(mismatches[0].contains("calendar mismatch")); 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, extract_pk4_entry_bytes, extract_pk4_entry_file, inspect_pk4_bytes, inspect_pk4_file,
}; };
pub use runtime::{ pub use runtime::{
RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeWorldRestoreState, RuntimeWorldRestoreState,
}; };
pub use smp::{ pub use smp::{

View file

@ -11,6 +11,63 @@ pub struct RuntimeCompany {
pub debt: u64, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeEventRecord { pub struct RuntimeEventRecord {
pub record_id: u32, pub record_id: u32,
@ -20,6 +77,27 @@ pub struct RuntimeEventRecord {
pub service_count: u32, pub service_count: u32,
#[serde(default)] #[serde(default)]
pub marks_collection_dirty: bool, 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)] #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
@ -131,6 +209,14 @@ impl RuntimeState {
if !seen_record_ids.insert(record.record_id) { if !seen_record_ids.insert(record.record_id) {
return Err(format!("duplicate record_id {}", 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() { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -298,4 +455,91 @@ mod tests {
assert!(state.validate().is_err()); 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 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]; const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4];
@ -39,6 +44,12 @@ pub struct ServiceEvent {
pub kind: String, pub kind: String,
pub trigger_kind: Option<u8>, pub trigger_kind: Option<u8>,
pub serviced_record_ids: Vec<u32>, 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, pub dirty_rerun: bool,
} }
@ -51,6 +62,23 @@ pub struct StepResult {
pub service_events: Vec<ServiceEvent>, 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( pub fn execute_step_command(
state: &mut RuntimeState, state: &mut RuntimeState,
command: &StepCommand, command: &StepCommand,
@ -67,11 +95,11 @@ pub fn execute_step_command(
} }
StepCommand::StepCount { steps } => step_count(state, *steps, &mut boundary_events), StepCommand::StepCount { steps } => step_count(state, *steps, &mut boundary_events),
StepCommand::ServiceTriggerKind { trigger_kind } => { StepCommand::ServiceTriggerKind { trigger_kind } => {
service_trigger_kind(state, *trigger_kind, &mut service_events); service_trigger_kind(state, *trigger_kind, &mut service_events)?;
0 0
} }
StepCommand::ServicePeriodicBoundary => { StepCommand::ServicePeriodicBoundary => {
service_periodic_boundary(state, &mut service_events); service_periodic_boundary(state, &mut service_events)?;
0 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; state.service_state.periodic_boundary_calls += 1;
for trigger_kind in PERIODIC_TRIGGER_KIND_ORDER { 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( fn service_trigger_kind(
state: &mut RuntimeState, state: &mut RuntimeState,
trigger_kind: u8, trigger_kind: u8,
service_events: &mut Vec<ServiceEvent>, 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 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; let mut dirty_rerun = false;
*state *state
@ -159,27 +211,237 @@ fn service_trigger_kind(
.entry(trigger_kind) .entry(trigger_kind)
.or_insert(0) += 1; .or_insert(0) += 1;
for record in &mut state.event_runtime_records { for index in eligible_indices {
if record.active && record.trigger_kind == trigger_kind { 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; record.service_count += 1;
serviced_record_ids.push(record.record_id); if record_one_shot {
state.service_state.total_event_record_services += 1; record.has_fired = true;
if trigger_kind != 0x0a && record.marks_collection_dirty {
dirty_rerun = 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 { service_events.push(ServiceEvent {
kind: "trigger_dispatch".to_string(), kind: "trigger_dispatch".to_string(),
trigger_kind: Some(trigger_kind), trigger_kind: Some(trigger_kind),
serviced_record_ids, 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, dirty_rerun,
}); });
if dirty_rerun { if dirty_rerun {
state.service_state.dirty_rerun_count += 1; 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 super::*;
use crate::{ use crate::{
CalendarPoint, RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState, CalendarPoint, RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
RuntimeServiceState, RuntimeWorldRestoreState, RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState,
RuntimeWorldRestoreState,
}; };
fn state() -> RuntimeState { fn state() -> RuntimeState {
@ -267,6 +530,12 @@ mod tests {
active: true, active: true,
service_count: 0, service_count: 0,
marks_collection_dirty: true, marks_collection_dirty: true,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::SetWorldFlag {
key: "runtime.effect_fired".to_string(),
value: true,
}],
}, },
RuntimeEventRecord { RuntimeEventRecord {
record_id: 2, record_id: 2,
@ -274,6 +543,12 @@ mod tests {
active: true, active: true,
service_count: 0, service_count: 0,
marks_collection_dirty: false, marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::AdjustCompanyCash {
target: RuntimeCompanyTarget::AllActive,
delta: 5,
}],
}, },
RuntimeEventRecord { RuntimeEventRecord {
record_id: 3, record_id: 3,
@ -281,6 +556,12 @@ mod tests {
active: true, active: true,
service_count: 0, service_count: 0,
marks_collection_dirty: false, marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![RuntimeEffect::SetSpecialCondition {
label: "Dirty rerun fired".to_string(),
value: 1,
}],
}, },
], ],
..state() ..state()
@ -296,6 +577,9 @@ mod tests {
assert_eq!(state.event_runtime_records[0].service_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[1].service_count, 1);
assert_eq!(state.event_runtime_records[2].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!( assert_eq!(
state.service_state.trigger_dispatch_counts.get(&1), state.service_state.trigger_dispatch_counts.get(&1),
Some(&1) Some(&1)
@ -308,5 +592,444 @@ mod tests {
state.service_state.trigger_dispatch_counts.get(&0x0a), state.service_state.trigger_dispatch_counts.get(&0x0a),
Some(&1) 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());
} }
} }

View file

@ -64,19 +64,23 @@ Current local tool status:
## Next Focus ## Next Focus
The atlas milestone is broad enough that the next implementation focus shifts downward into runtime The atlas milestone is broad enough that the next implementation focus has already shifted downward
rehosting. The highest-value next passes are: into runtime rehosting. The current runtime baseline now includes deterministic stepping, periodic
trigger dispatch, normalized runtime effects, fixture execution, state-diff tooling, and initial
persistence surfaces.
- preserve the atlas and function map as the source of subsystem boundaries while avoiding further The highest-value next passes are now:
shell-first implementation bets
- stand up a bottom-up runtime core that can load state, execute deterministic world work, and dump - preserve the atlas and function map as the source of subsystem boundaries while continuing to
normalized diffs without depending on the shell controller or presentation path avoid shell-first implementation bets
- use `rrt-hook` primarily as an optional capture tool for fixtures and state probes, not as the - broaden the normalized event-service layer through staged event-record mutation and follow-on
first execution environment record behavior
- choose early rewrite targets from the lower simulation, event-service, and persistence boundaries - deepen captured-runtime and round-trip fixture coverage on top of the existing runtime CLI and
before attempting shell, input, or presentation replacement fixture surfaces
- write milestone-scoped implementation notes in `docs/runtime-rehost-plan.md` before expanding the - use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution
workspace crates environment
- keep `docs/runtime-rehost-plan.md` current as the runtime baseline and next implementation slice
change
Regenerate the initial exports with: Regenerate the initial exports with:

View file

@ -9,6 +9,24 @@ This plan assumes the current shell and presentation path remain unreliable for
therefore treat shell recovery as a later adapter problem rather than as the primary execution therefore treat shell recovery as a later adapter problem rather than as the primary execution
milestone. milestone.
## Current Baseline
The repo is already past pure scaffolding.
Implemented today:
- `rrt-runtime` exists with a deterministic calendar model, step commands, runtime summaries, and
normalized runtime state validation
- periodic trigger dispatch exists, including ordered periodic maintenance, dirty rerun `0x0a`, and
a first normalized runtime-effect surface
- snapshots, state dumps, save-slice projection, and normalized state diffing already exist in the
CLI and fixture layers
- checked-in runtime fixtures already cover deterministic stepping, periodic service, direct trigger
service, snapshot-backed inputs, and normalized state-fragment assertions
That means the next implementation work is breadth, not bootstrap. The recommended next slice is
normalized event-service breadth through staged event-record mutation and follow-on records.
## Why This Boundary ## Why This Boundary
Current static analysis points to one important constraint: the existing gameplay cadence is still Current static analysis points to one important constraint: the existing gameplay cadence is still
@ -105,7 +123,7 @@ Poor early targets:
## Milestones ## Milestones
### Milestone 0: Scaffolding ### Milestone 0: Scaffolding (complete)
Goal: Goal:
@ -127,7 +145,7 @@ Exit criteria:
- one sample fixture parses and normalizes successfully - one sample fixture parses and normalizes successfully
- the new crates build in the workspace - the new crates build in the workspace
### Milestone 1: Deterministic Step Kernel ### Milestone 1: Deterministic Step Kernel (complete)
Goal: Goal:
@ -148,7 +166,7 @@ Exit criteria:
- the same fixture can run for N steps with identical results across repeated runs - the same fixture can run for N steps with identical results across repeated runs
- state summaries cover the calendar tuple and a small set of world counters - state summaries cover the calendar tuple and a small set of world counters
### Milestone 2: Periodic Service Kernel ### Milestone 2: Periodic Service Kernel (partially complete)
Goal: Goal:
@ -166,7 +184,15 @@ Exit criteria:
- one fixture can execute periodic maintenance without shell state - one fixture can execute periodic maintenance without shell state
- trigger-kind-specific effects can be observed in a normalized diff - trigger-kind-specific effects can be observed in a normalized diff
### Milestone 3: Persistence Boundary Current status:
- periodic trigger ordering is implemented
- normalized trigger-side effects already exist for world flags, company cash/debt, candidate
availability, and special conditions
- one-shot handling and dirty reruns are already covered by synthetic fixtures
- the missing breadth is event-graph mutation and richer trigger-family behavior
### Milestone 3: Persistence Boundary (partially complete)
Goal: Goal:
@ -182,6 +208,13 @@ Exit criteria:
- one captured runtime fixture can be round-tripped with stable normalized output - one captured runtime fixture can be round-tripped with stable normalized output
Current status:
- runtime snapshots and state dumps are implemented
- `.smp` save inspection and partial save-slice projection already feed normalized runtime state
- the remaining gap is broader captured-runtime and round-trip fixture depth, not the first
persistence surface
### Milestone 4: Domain Expansion ### Milestone 4: Domain Expansion
Goal: Goal:
@ -259,6 +292,7 @@ Each fixture should contain:
- command list - command list
- expected summary - expected summary
- optional expected normalized full state - optional expected normalized full state
- optional expected normalized state fragment when only part of the final state matters
## Normalization Policy ## Normalization Policy
@ -297,216 +331,56 @@ fixture format should make missing dependencies obvious.
The first two milestones should remain deliberately small. Do not pull in company UI, world-view The first two milestones should remain deliberately small. Do not pull in company UI, world-view
camera work, or shell windows just because their names are nearby in the call graph. camera work, or shell windows just because their names are nearby in the call graph.
## Milestone 0 Specifics ## Implemented Baseline
This milestone is mostly repo shaping and interface definition. The currently implemented normalized runtime surface is:
### Proposed crate layout - `CalendarPoint`, `RuntimeState`, `StepCommand`, `StepResult`, and `RuntimeSummary`
- fixture loading from inline state, snapshots, and state dumps
- `runtime validate-fixture`, `runtime summarize-fixture`, `runtime export-fixture-state`,
`runtime summarize-state`, `runtime import-state`, and `runtime diff-state`
- deterministic stepping, periodic trigger dispatch, one-shot event handling, dirty reruns, and a
first normalized runtime-effect vocabulary
- save-side inspection and partial state projection for `.smp` inputs
`crates/rrt-runtime` Checked-in fixture families already include:
- `src/lib.rs` - deterministic minimal-world stepping
- `src/calendar.rs` - periodic boundary service
- `src/runtime.rs` - direct trigger-service mutation
- `src/step.rs` - snapshot-backed fixture execution
- `src/summary.rs`
`crates/rrt-fixtures` ## Next Slice
- `src/lib.rs` The recommended next implementation slice is normalized event-service breadth through staged
- `src/schema.rs` event-record mutation.
- `src/load.rs`
- `src/normalize.rs`
- `src/diff.rs`
### Proposed first public types Target behavior:
In `rrt-runtime`: - allow one serviced record to append a follow-on runtime record
- allow one serviced record to activate, deactivate, or remove another runtime record
- stage those graph mutations during the pass and commit them only after the pass finishes
- commit staged mutations in exact emission order
- allow newly appended `0x0a` records to run in the dirty rerun after commit, but never in the
original pass snapshot
- `CalendarPoint` Public-model additions for that slice:
- `RuntimeState`
- `StepCommand`
- `StepResult`
- `RuntimeSummary`
In `rrt-fixtures`: - `RuntimeEventRecordTemplate`
- `RuntimeEffect::AppendEventRecord`
- `RuntimeEffect::ActivateEventRecord`
- `RuntimeEffect::DeactivateEventRecord`
- `RuntimeEffect::RemoveEventRecord`
- `FixtureDocument` Fixture work for that slice:
- `FixtureCommand`
- `ExpectedSummary`
- `NormalizedState`
### Proposed CLI surface - one synthetic fixture for append plus dirty rerun behavior
- one synthetic fixture for cross-pass activate/deactivate/remove semantics
- state-fragment assertions that lock final collection contents and per-record counters
Add a `runtime` command family to `rrt-cli`. Do not mix this slice with:
Initial commands: - territory-access or selected-profile parity
- placed-structure batch placement parity
- `rrt-cli runtime validate-fixture <fixture.json>` - shell queue/modal behavior
- `rrt-cli runtime summarize-fixture <fixture.json>` - packed RT3 event-row import/export parity
- `rrt-cli runtime diff-state <left.json> <right.json>`
These commands do not require a complete runtime implementation yet. They only need to parse,
normalize, and summarize.
### Proposed fixture shape
Example:
```json
{
"format_version": 1,
"fixture_id": "minimal-world-step-smoke",
"source": {
"kind": "synthetic"
},
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flags": {},
"companies": [],
"event_runtime_records": []
},
"commands": [
{
"kind": "advance_to",
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 1,
"tick_slot": 0
}
}
],
"expected_summary": {
"calendar_year": 1830
}
}
```
### Milestone 0 task list
1. Add the two new workspace crates.
2. Add serde-backed fixture schema types.
3. Add a small summary model for runtime fixtures.
4. Extend `rrt-cli` parsing with the `runtime` command family.
5. Check in one synthetic fixture under a new tracked fixtures directory.
6. Add tests for fixture parsing and normalization.
## Milestone 1 Specifics
This milestone is the first real execution step.
### Scope
Implement only enough runtime to support:
- calendar-point representation
- comparison and ordering
- `advance_to` over a reduced world state
- summary and diff output
Do not yet model:
- shell-facing frame accumulators
- input
- camera or cursor state
- shell windows
- full company, site, or cargo behavior
### Proposed runtime model
`CalendarPoint`
- year
- month_slot
- phase_slot
- tick_slot
Methods:
- ordering and comparison
- step forward one minimal unit
- convert to and from normalized serialized form
`RuntimeState`
- current calendar point
- selected year or absolute calendar scalar if needed
- minimal world counters
- optional minimal event-service scratch state
`StepCommand`
- `AdvanceTo(CalendarPoint)`
- `StepCount(u32)`
`StepResult`
- start summary
- end summary
- steps_executed
- boundary_events
### Milestone 1 execution API
Suggested Rust signature:
```rust
pub fn execute_step_command(
state: &mut RuntimeState,
command: StepCommand,
) -> StepResult
```
Internally this should call a narrow helper shaped like:
```rust
pub fn advance_to_target_calendar_point(
state: &mut RuntimeState,
target: CalendarPoint,
) -> StepResult
```
### Milestone 1 fixture set
Add at least these fixtures:
1. `minimal-world-advance-one-phase`
2. `minimal-world-advance-multiple-phases`
3. `minimal-world-step-count`
4. `minimal-world-repeatability`
### Milestone 1 verification
Required checks:
- repeated runs produce byte-identical normalized summaries
- target calendar point is reached exactly
- step count matches expected traversal
- backward targets fail cleanly or no-op according to chosen policy
### Milestone 1 task list
1. Implement `CalendarPoint`.
2. Implement reduced `RuntimeState`.
3. Implement `advance_to_target_calendar_point`.
4. Implement CLI execution for one fixture command list.
5. Emit normalized summaries after execution.
6. Add deterministic regression tests for the initial fixtures.
## Immediate Next Actions
After this document lands, the recommended first implementation sequence is:
1. add `rrt-runtime` and `rrt-fixtures`
2. extend `rrt-cli` with `runtime validate-fixture`
3. add one synthetic fixture
4. implement `CalendarPoint`
5. implement one narrow `advance_to` path
That sequence gives the project a headless execution backbone without needing shell recovery first.

View file

@ -0,0 +1,82 @@
{
"format_version": 1,
"fixture_id": "event-follow-on-dirty-rerun-smoke",
"source": {
"kind": "synthetic",
"description": "Synthetic normalized event-service fixture covering staged follow-on record append and dirty rerun dispatch."
},
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flags": {
"sandbox": false
},
"companies": [],
"event_runtime_records": [
{
"record_id": 100,
"trigger_kind": 1,
"active": true,
"marks_collection_dirty": true,
"effects": [
{
"kind": "append_event_record",
"record": {
"record_id": 101,
"trigger_kind": 10,
"active": true,
"effects": [
{
"kind": "set_world_flag",
"key": "dirty_rerun_follow_on",
"value": true
}
]
}
}
]
}
]
},
"commands": [
{
"kind": "service_trigger_kind",
"trigger_kind": 1
}
],
"expected_summary": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flag_count": 2,
"company_count": 0,
"event_runtime_record_count": 2,
"total_event_record_service_count": 2,
"periodic_boundary_call_count": 0,
"total_trigger_dispatch_count": 2,
"dirty_rerun_count": 1,
"total_company_cash": 0
},
"expected_state_fragment": {
"world_flags": {
"dirty_rerun_follow_on": true
},
"event_runtime_records": [
{
"record_id": 100,
"service_count": 1
},
{
"record_id": 101,
"service_count": 1
}
]
}
}

View file

@ -0,0 +1,157 @@
{
"format_version": 1,
"fixture_id": "event-record-lifecycle-smoke",
"source": {
"kind": "synthetic",
"description": "Synthetic normalized event-service fixture covering staged append, activate, deactivate, and remove semantics across sequential trigger passes."
},
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flags": {
"sandbox": false
},
"companies": [],
"event_runtime_records": [
{
"record_id": 60,
"trigger_kind": 7,
"active": true,
"one_shot": true,
"effects": [
{
"kind": "append_event_record",
"record": {
"record_id": 64,
"trigger_kind": 7,
"active": true,
"effects": [
{
"kind": "set_candidate_availability",
"name": "Appended Industry",
"value": 1
}
]
}
},
{
"kind": "deactivate_event_record",
"record_id": 61
},
{
"kind": "activate_event_record",
"record_id": 62
},
{
"kind": "remove_event_record",
"record_id": 63
}
]
},
{
"record_id": 61,
"trigger_kind": 7,
"active": true,
"effects": [
{
"kind": "set_world_flag",
"key": "deactivated_after_first_pass",
"value": true
}
]
},
{
"record_id": 62,
"trigger_kind": 7,
"active": false,
"effects": [
{
"kind": "set_special_condition",
"label": "Activated On Second Pass",
"value": 1
}
]
},
{
"record_id": 63,
"trigger_kind": 7,
"active": true,
"effects": [
{
"kind": "set_world_flag",
"key": "removed_after_first_pass",
"value": true
}
]
}
]
},
"commands": [
{
"kind": "service_trigger_kind",
"trigger_kind": 7
},
{
"kind": "service_trigger_kind",
"trigger_kind": 7
}
],
"expected_summary": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flag_count": 3,
"company_count": 0,
"event_runtime_record_count": 4,
"candidate_availability_count": 1,
"special_condition_count": 1,
"enabled_special_condition_count": 1,
"total_event_record_service_count": 5,
"periodic_boundary_call_count": 0,
"total_trigger_dispatch_count": 2,
"dirty_rerun_count": 0,
"total_company_cash": 0
},
"expected_state_fragment": {
"candidate_availability": {
"Appended Industry": 1
},
"special_conditions": {
"Activated On Second Pass": 1
},
"world_flags": {
"deactivated_after_first_pass": true,
"removed_after_first_pass": true
},
"event_runtime_records": [
{
"record_id": 60,
"service_count": 1,
"has_fired": true,
"active": true
},
{
"record_id": 61,
"service_count": 1,
"active": false
},
{
"record_id": 62,
"service_count": 1,
"active": true
},
{
"record_id": 64,
"service_count": 1,
"active": true
}
]
}
}

View file

@ -15,23 +15,52 @@
"world_flags": { "world_flags": {
"sandbox": false "sandbox": false
}, },
"companies": [], "companies": [
{
"company_id": 1,
"current_cash": 250000,
"debt": 1000
}
],
"event_runtime_records": [ "event_runtime_records": [
{ {
"record_id": 1, "record_id": 1,
"trigger_kind": 1, "trigger_kind": 1,
"active": true, "active": true,
"marks_collection_dirty": true "marks_collection_dirty": true,
"effects": [
{
"kind": "set_world_flag",
"key": "runtime.effect_fired",
"value": true
}
]
}, },
{ {
"record_id": 2, "record_id": 2,
"trigger_kind": 4, "trigger_kind": 4,
"active": true "active": true,
"effects": [
{
"kind": "adjust_company_cash",
"target": {
"kind": "all_active"
},
"delta": 5
}
]
}, },
{ {
"record_id": 3, "record_id": 3,
"trigger_kind": 10, "trigger_kind": 10,
"active": true "active": true,
"effects": [
{
"kind": "set_special_condition",
"label": "Dirty rerun fired",
"value": 1
}
]
} }
] ]
}, },
@ -47,13 +76,27 @@
"phase_slot": 0, "phase_slot": 0,
"tick_slot": 0 "tick_slot": 0
}, },
"world_flag_count": 1, "world_flag_count": 2,
"company_count": 0, "company_count": 1,
"event_runtime_record_count": 3, "event_runtime_record_count": 3,
"total_event_record_service_count": 3, "total_event_record_service_count": 3,
"periodic_boundary_call_count": 1, "periodic_boundary_call_count": 1,
"total_trigger_dispatch_count": 7, "total_trigger_dispatch_count": 7,
"dirty_rerun_count": 1, "dirty_rerun_count": 1,
"total_company_cash": 0 "total_company_cash": 250005
},
"expected_state_fragment": {
"world_flags": {
"runtime.effect_fired": true
},
"companies": [
{
"company_id": 1,
"current_cash": 250005
}
],
"special_conditions": {
"Dirty rerun fired": 1
}
} }
} }

View file

@ -0,0 +1,134 @@
{
"format_version": 1,
"fixture_id": "service-trigger-kind-effects-smoke",
"source": {
"kind": "synthetic",
"description": "Synthetic normalized event-service fixture covering direct trigger dispatch, targeted company mutation, and one-shot behavior."
},
"state": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flags": {
"sandbox": false
},
"companies": [
{
"company_id": 1,
"current_cash": 10,
"debt": 5
},
{
"company_id": 2,
"current_cash": 20,
"debt": 8
}
],
"event_runtime_records": [
{
"record_id": 10,
"trigger_kind": 7,
"active": true,
"effects": [
{
"kind": "set_candidate_availability",
"name": "Steel Mill",
"value": 1
},
{
"kind": "adjust_company_cash",
"target": {
"kind": "ids",
"ids": [2]
},
"delta": 4
},
{
"kind": "adjust_company_debt",
"target": {
"kind": "ids",
"ids": [2]
},
"delta": -3
}
]
},
{
"record_id": 11,
"trigger_kind": 7,
"active": true,
"one_shot": true,
"effects": [
{
"kind": "set_special_condition",
"label": "One Shot Trigger",
"value": 1
}
]
}
]
},
"commands": [
{
"kind": "service_trigger_kind",
"trigger_kind": 7
},
{
"kind": "service_trigger_kind",
"trigger_kind": 7
}
],
"expected_summary": {
"calendar": {
"year": 1830,
"month_slot": 0,
"phase_slot": 0,
"tick_slot": 0
},
"world_flag_count": 1,
"company_count": 2,
"event_runtime_record_count": 2,
"candidate_availability_count": 1,
"special_condition_count": 1,
"enabled_special_condition_count": 1,
"total_event_record_service_count": 3,
"periodic_boundary_call_count": 0,
"total_trigger_dispatch_count": 2,
"dirty_rerun_count": 0,
"total_company_cash": 38
},
"expected_state_fragment": {
"candidate_availability": {
"Steel Mill": 1
},
"special_conditions": {
"One Shot Trigger": 1
},
"companies": [
{
"company_id": 1,
"current_cash": 10,
"debt": 5
},
{
"company_id": 2,
"current_cash": 28,
"debt": 2
}
],
"event_runtime_records": [
{
"record_id": 10,
"service_count": 2
},
{
"record_id": 11,
"service_count": 1,
"has_fired": true
}
]
}
}