Expand runtime event graph service
This commit is contained in:
parent
049ffa6bd8
commit
6ebe5fffeb
14 changed files with 1803 additions and 254 deletions
21
README.md
21
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::{
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
record.has_fired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serviced_record_ids.push(record_id);
|
||||||
state.service_state.total_event_record_services += 1;
|
state.service_state.total_event_record_services += 1;
|
||||||
if trigger_kind != 0x0a && record.marks_collection_dirty {
|
if trigger_kind != 0x0a && record_marks_collection_dirty {
|
||||||
dirty_rerun = true;
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
82
fixtures/runtime/event-follow-on-dirty-rerun-smoke.json
Normal file
82
fixtures/runtime/event-follow-on-dirty-rerun-smoke.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
157
fixtures/runtime/event-record-lifecycle-smoke.json
Normal file
157
fixtures/runtime/event-record-lifecycle-smoke.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
fixtures/runtime/service-trigger-kind-effects-smoke.json
Normal file
134
fixtures/runtime/service-trigger-kind-effects-smoke.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue