diff --git a/README.md b/README.md index 81ad8f1..4c289f0 100644 --- a/README.md +++ b/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. 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 -minimal PE32 Rust hook that can load into RT3 under Wine without changing behavior. +individual functions as we build them out. The active implementation milestone is now a headless +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 @@ -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: - `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-hook`: minimal Windows DLL scaffold that currently builds a `dinput8.dll` proxy for - low-risk in-process loading experiments under Wine +- `rrt-runtime`: headless runtime state, stepping, normalized event service, and persistence-facing + runtime types +- `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 `WINEDLLOVERRIDES=dinput8=n,b`, and expects `rrt_hook_attach.log` to appear. diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index 95a472b..8c95a5c 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -4,7 +4,10 @@ use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; -use rrt_fixtures::{FixtureValidationReport, load_fixture_document, validate_fixture_document}; +use rrt_fixtures::{ + FixtureValidationReport, JsonDiffEntry, compare_expected_state_fragment, diff_json_values, + load_fixture_document, normalize_runtime_state, validate_fixture_document, +}; use rrt_model::{ BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH, REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS, @@ -95,6 +98,10 @@ enum Command { fixture_path: PathBuf, output_path: PathBuf, }, + RuntimeDiffState { + left_path: PathBuf, + right_path: PathBuf, + }, RuntimeSummarizeState { snapshot_path: PathBuf, }, @@ -195,6 +202,8 @@ struct RuntimeFixtureSummaryReport { final_summary: RuntimeSummary, expected_summary_matches: bool, expected_summary_mismatches: Vec, + expected_state_fragment_matches: bool, + expected_state_fragment_mismatches: Vec, } #[derive(Debug, Serialize)] @@ -203,6 +212,13 @@ struct RuntimeStateSummaryReport { summary: RuntimeSummary, } +#[derive(Debug, Serialize)] +struct RuntimeStateDiffReport { + matches: bool, + difference_count: usize, + differences: Vec, +} + #[derive(Debug, Serialize)] struct RuntimeSmpInspectionOutput { path: String, @@ -724,6 +740,12 @@ fn real_main() -> Result<(), Box> { } => { run_runtime_export_fixture_state(&fixture_path, &output_path)?; } + Command::RuntimeDiffState { + left_path, + right_path, + } => { + run_runtime_diff_state(&left_path, &right_path)?; + } Command::RuntimeSummarizeState { snapshot_path } => { run_runtime_summarize_state(&snapshot_path)?; } @@ -859,6 +881,14 @@ fn parse_command() -> Result> { output_path: PathBuf::from(output_path), }) } + [command, subcommand, left_path, right_path] + if command == "runtime" && subcommand == "diff-state" => + { + Ok(Command::RuntimeDiffState { + left_path: PathBuf::from(left_path), + right_path: PathBuf::from(right_path), + }) + } [command, subcommand, path] if command == "runtime" && subcommand == "summarize-state" => { Ok(Command::RuntimeSummarizeState { snapshot_path: PathBuf::from(path), @@ -1039,7 +1069,7 @@ fn parse_command() -> Result> { }) } _ => Err( - "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime summarize-save-load | runtime load-save-slice | runtime import-save-state | runtime inspect-pk4 | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-recipe-book-lines [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime compare-post-special-conditions-scalars [fileN...] | runtime scan-candidate-table-headers | runtime scan-special-conditions | runtime scan-aligned-runtime-rule-band | runtime scan-post-special-conditions-scalars | runtime scan-post-special-conditions-tail | runtime scan-recipe-book-lines | runtime export-profile-block ]" + "usage: rrt-cli [validate [repo-root] | finance eval | finance diff | runtime validate-fixture | runtime summarize-fixture | runtime export-fixture-state | runtime diff-state | runtime summarize-state | runtime import-state | runtime inspect-smp | runtime summarize-save-load | runtime load-save-slice | runtime import-save-state | runtime inspect-pk4 | runtime inspect-win | runtime extract-pk4-entry | runtime inspect-campaign-exe | runtime compare-classic-profile [saveN.gms...] | runtime compare-105-profile [saveN.gms...] | runtime compare-candidate-table [fileN...] | runtime compare-recipe-book-lines [fileN...] | runtime compare-setup-payload-core [fileN...] | runtime compare-setup-launch-payload [fileN...] | runtime compare-post-special-conditions-scalars [fileN...] | runtime scan-candidate-table-headers | runtime scan-special-conditions | runtime scan-aligned-runtime-rule-band | runtime scan-post-special-conditions-scalars | runtime scan-post-special-conditions-tail | runtime scan-recipe-book-lines | runtime export-profile-block ]" .into(), ), } @@ -1083,20 +1113,31 @@ fn run_runtime_summarize_fixture(fixture_path: &Path) -> Result<(), Box { + let normalized_state = normalize_runtime_state(&state)?; + compare_expected_state_fragment(expected_fragment, &normalized_state) + } + None => Vec::new(), + }; let report = RuntimeFixtureSummaryReport { fixture_id: fixture.fixture_id, command_count: fixture.commands.len(), - expected_summary_matches: mismatches.is_empty(), - expected_summary_mismatches: mismatches.clone(), + expected_summary_matches: expected_summary_mismatches.is_empty(), + expected_summary_mismatches: expected_summary_mismatches.clone(), + expected_state_fragment_matches: expected_state_fragment_mismatches.is_empty(), + expected_state_fragment_mismatches: expected_state_fragment_mismatches.clone(), final_summary, }; println!("{}", serde_json::to_string_pretty(&report)?); - if !mismatches.is_empty() { + if !expected_summary_mismatches.is_empty() || !expected_state_fragment_mismatches.is_empty() { + let mut mismatch_messages = expected_summary_mismatches; + mismatch_messages.extend(expected_state_fragment_mismatches); return Err(format!( "fixture summary mismatched expected output: {}", - mismatches.join("; ") + mismatch_messages.join("; ") ) .into()); } @@ -1159,6 +1200,33 @@ fn run_runtime_summarize_state(snapshot_path: &Path) -> Result<(), Box Result<(), Box> { + 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> { + if let Ok(snapshot) = load_runtime_snapshot_document(path) { + validate_runtime_snapshot_document(&snapshot) + .map_err(|err| format!("invalid runtime snapshot: {err}"))?; + return normalize_runtime_state(&snapshot.state); + } + + let import = load_runtime_state_import(path)?; + normalize_runtime_state(&import.state) +} + fn run_runtime_import_state( input_path: &Path, output_path: &Path, @@ -3834,6 +3902,14 @@ mod tests { "company_count": 0, "event_runtime_record_count": 0, "total_company_cash": 0 + }, + "expected_state_fragment": { + "calendar": { + "tick_slot": 3 + }, + "world_flags": { + "sandbox": false + } } }); let path = write_temp_json("runtime-fixture", &fixture); @@ -3938,6 +4014,121 @@ mod tests { let _ = fs::remove_file(output_path); } + #[test] + fn diffs_runtime_states_recursively() { + let left = serde_json::json!({ + "format_version": 1, + "snapshot_id": "left", + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 1 + }, + "world_flags": { + "sandbox": false + }, + "companies": [] + } + }); + let right = serde_json::json!({ + "format_version": 1, + "snapshot_id": "right", + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 2 + }, + "world_flags": { + "sandbox": true + }, + "companies": [] + } + }); + let left_path = write_temp_json("runtime-diff-left", &left); + let right_path = write_temp_json("runtime-diff-right", &right); + + run_runtime_diff_state(&left_path, &right_path).expect("runtime diff should succeed"); + + let _ = fs::remove_file(left_path); + let _ = fs::remove_file(right_path); + } + + #[test] + fn diffs_runtime_states_with_event_record_additions_and_removals() { + let left = serde_json::json!({ + "format_version": 1, + "snapshot_id": "left-events", + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 1 + }, + "world_flags": { + "sandbox": false + }, + "companies": [], + "event_runtime_records": [ + { + "record_id": 1, + "trigger_kind": 7, + "active": true + }, + { + "record_id": 2, + "trigger_kind": 7, + "active": false + } + ] + } + }); + let right = serde_json::json!({ + "format_version": 1, + "snapshot_id": "right-events", + "state": { + "calendar": { + "year": 1830, + "month_slot": 0, + "phase_slot": 0, + "tick_slot": 1 + }, + "world_flags": { + "sandbox": false + }, + "companies": [], + "event_runtime_records": [ + { + "record_id": 1, + "trigger_kind": 7, + "active": true + } + ] + } + }); + let left_path = write_temp_json("runtime-diff-events-left", &left); + let right_path = write_temp_json("runtime-diff-events-right", &right); + + let left_state = + load_normalized_runtime_state(&left_path).expect("left runtime state should load"); + let right_state = + load_normalized_runtime_state(&right_path).expect("right runtime state should load"); + let differences = diff_json_values(&left_state, &right_state); + + assert!( + differences + .iter() + .any(|entry| entry.path == "$.event_runtime_records[1]") + ); + + let _ = fs::remove_file(left_path); + let _ = fs::remove_file(right_path); + } + #[test] fn diffs_classic_profile_samples_across_multiple_files() { let sample_a = RuntimeClassicProfileSample { diff --git a/crates/rrt-fixtures/src/lib.rs b/crates/rrt-fixtures/src/lib.rs index 9476f53..289914e 100644 --- a/crates/rrt-fixtures/src/lib.rs +++ b/crates/rrt-fixtures/src/lib.rs @@ -8,5 +8,6 @@ pub use load::{load_fixture_document, load_fixture_document_from_str}; pub use normalize::normalize_runtime_state; pub use schema::{ ExpectedRuntimeSummary, FIXTURE_FORMAT_VERSION, FixtureDocument, FixtureSource, - FixtureStateOrigin, FixtureValidationReport, RawFixtureDocument, validate_fixture_document, + FixtureStateOrigin, FixtureValidationReport, RawFixtureDocument, + compare_expected_state_fragment, validate_fixture_document, }; diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index 98af2a4..b9c335e 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -64,6 +64,7 @@ fn resolve_raw_fixture_document( state_origin, commands: raw.commands, expected_summary: raw.expected_summary, + expected_state_fragment: raw.expected_state_fragment, }) } diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index fec1da5..45f7efd 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; use rrt_runtime::{RuntimeState, RuntimeSummary, StepCommand}; @@ -461,6 +462,8 @@ pub struct FixtureDocument { pub commands: Vec, #[serde(default)] pub expected_summary: ExpectedRuntimeSummary, + #[serde(default)] + pub expected_state_fragment: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -483,6 +486,8 @@ pub struct RawFixtureDocument { pub commands: Vec, #[serde(default)] pub expected_summary: ExpectedRuntimeSummary, + #[serde(default)] + pub expected_state_fragment: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -493,6 +498,54 @@ pub struct FixtureValidationReport { pub issues: Vec, } +pub fn compare_expected_state_fragment(expected: &Value, actual: &Value) -> Vec { + 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, +) { + match (expected, actual) { + (Value::Object(expected_map), Value::Object(actual_map)) => { + for (key, expected_value) in expected_map { + let next_path = format!("{path}.{key}"); + match actual_map.get(key) { + Some(actual_value) => compare_expected_state_fragment_at_path( + &next_path, + expected_value, + actual_value, + mismatches, + ), + None => mismatches.push(format!("{next_path} missing in actual state")), + } + } + } + (Value::Array(expected_items), Value::Array(actual_items)) => { + for (index, expected_item) in expected_items.iter().enumerate() { + let next_path = format!("{path}[{index}]"); + match actual_items.get(index) { + Some(actual_item) => compare_expected_state_fragment_at_path( + &next_path, + expected_item, + actual_item, + mismatches, + ), + None => mismatches.push(format!("{next_path} missing in actual state")), + } + } + } + _ if expected != actual => mismatches.push(format!( + "{path} mismatch: expected {expected:?}, got {actual:?}" + )), + _ => {} + } +} + pub fn validate_fixture_document(document: &FixtureDocument) -> FixtureValidationReport { let mut issues = Vec::new(); @@ -609,4 +662,36 @@ mod tests { assert_eq!(mismatches.len(), 1); assert!(mismatches[0].contains("calendar mismatch")); } + + #[test] + fn compares_expected_state_fragment_recursively() { + let expected = serde_json::json!({ + "world_flags": { + "sandbox": false + }, + "companies": [ + { + "company_id": 1 + } + ] + }); + let actual = serde_json::json!({ + "world_flags": { + "sandbox": false, + "runtime.effect_fired": true + }, + "companies": [ + { + "company_id": 1, + "current_cash": 250000 + } + ] + }); + + let mismatches = compare_expected_state_fragment(&expected, &actual); + assert!( + mismatches.is_empty(), + "unexpected mismatches: {mismatches:?}" + ); + } } diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index 6990902..a4a349e 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -29,7 +29,8 @@ pub use pk4::{ extract_pk4_entry_bytes, extract_pk4_entry_file, inspect_pk4_bytes, inspect_pk4_file, }; pub use runtime::{ - RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, + RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, + RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, }; pub use smp::{ diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index cee6d79..1d3cdf9 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -11,6 +11,63 @@ pub struct RuntimeCompany { pub debt: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuntimeCompanyTarget { + AllActive, + Ids { ids: Vec }, +} + +#[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, + }, + 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, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeEventRecord { pub record_id: u32, @@ -20,6 +77,27 @@ pub struct RuntimeEventRecord { pub service_count: u32, #[serde(default)] pub marks_collection_dirty: bool, + #[serde(default)] + pub one_shot: bool, + #[serde(default)] + pub has_fired: bool, + #[serde(default)] + pub effects: Vec, +} + +impl RuntimeEventRecordTemplate { + pub fn into_runtime_record(self) -> RuntimeEventRecord { + RuntimeEventRecord { + record_id: self.record_id, + trigger_kind: self.trigger_kind, + active: self.active, + service_count: 0, + marks_collection_dirty: self.marks_collection_dirty, + one_shot: self.one_shot, + has_fired: false, + effects: self.effects, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -131,6 +209,14 @@ impl RuntimeState { if !seen_record_ids.insert(record.record_id) { return Err(format!("duplicate record_id {}", record.record_id)); } + for (effect_index, effect) in record.effects.iter().enumerate() { + validate_runtime_effect(effect, &seen_company_ids).map_err(|err| { + format!( + "event_runtime_records[record_id={}].effects[{effect_index}] {err}", + record.record_id + ) + })?; + } } for key in self.world_flags.keys() { @@ -216,6 +302,77 @@ impl RuntimeState { } } +fn validate_runtime_effect( + effect: &RuntimeEffect, + valid_company_ids: &BTreeSet, +) -> 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, +) -> 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, +) -> Result<(), String> { + match target { + RuntimeCompanyTarget::AllActive => Ok(()), + RuntimeCompanyTarget::Ids { ids } => { + if ids.is_empty() { + return Err("target ids must not be empty".to_string()); + } + for company_id in ids { + if !valid_company_ids.contains(company_id) { + return Err(format!("target references unknown company_id {company_id}")); + } + } + Ok(()) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -298,4 +455,91 @@ mod tests { assert!(state.validate().is_err()); } + + #[test] + fn rejects_event_effect_targeting_unknown_company() { + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 1, + current_cash: 100, + debt: 0, + }], + event_runtime_records: vec![RuntimeEventRecord { + record_id: 7, + trigger_kind: 1, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::Ids { ids: vec![2] }, + delta: 50, + }], + }], + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + assert!(state.validate().is_err()); + } + + #[test] + fn rejects_template_effect_targeting_unknown_company() { + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 1, + current_cash: 100, + debt: 0, + }], + event_runtime_records: vec![RuntimeEventRecord { + record_id: 7, + trigger_kind: 1, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AppendEventRecord { + record: Box::new(RuntimeEventRecordTemplate { + record_id: 8, + trigger_kind: 0x0a, + active: true, + marks_collection_dirty: false, + one_shot: false, + effects: vec![RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::Ids { ids: vec![2] }, + delta: 50, + }], + }), + }], + }], + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + assert!(state.validate().is_err()); + } } diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index ab9548c..f5d4cfc 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -1,6 +1,11 @@ +use std::collections::BTreeSet; + use serde::{Deserialize, Serialize}; -use crate::{RuntimeState, RuntimeSummary, calendar::BoundaryEventKind}; +use crate::{ + RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate, RuntimeState, RuntimeSummary, + calendar::BoundaryEventKind, +}; const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4]; @@ -39,6 +44,12 @@ pub struct ServiceEvent { pub kind: String, pub trigger_kind: Option, pub serviced_record_ids: Vec, + pub applied_effect_count: u32, + pub mutated_company_ids: Vec, + pub appended_record_ids: Vec, + pub activated_record_ids: Vec, + pub deactivated_record_ids: Vec, + pub removed_record_ids: Vec, pub dirty_rerun: bool, } @@ -51,6 +62,23 @@ pub struct StepResult { pub service_events: Vec, } +#[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, + activated_record_ids: Vec, + deactivated_record_ids: Vec, + removed_record_ids: Vec, +} + pub fn execute_step_command( state: &mut RuntimeState, command: &StepCommand, @@ -67,11 +95,11 @@ pub fn execute_step_command( } StepCommand::StepCount { steps } => step_count(state, *steps, &mut boundary_events), StepCommand::ServiceTriggerKind { trigger_kind } => { - service_trigger_kind(state, *trigger_kind, &mut service_events); + service_trigger_kind(state, *trigger_kind, &mut service_events)?; 0 } StepCommand::ServicePeriodicBoundary => { - service_periodic_boundary(state, &mut service_events); + service_periodic_boundary(state, &mut service_events)?; 0 } }; @@ -137,20 +165,44 @@ fn boundary_kind_label(boundary: BoundaryEventKind) -> &'static str { } } -fn service_periodic_boundary(state: &mut RuntimeState, service_events: &mut Vec) { +fn service_periodic_boundary( + state: &mut RuntimeState, + service_events: &mut Vec, +) -> Result<(), String> { state.service_state.periodic_boundary_calls += 1; for trigger_kind in PERIODIC_TRIGGER_KIND_ORDER { - service_trigger_kind(state, trigger_kind, service_events); + service_trigger_kind(state, trigger_kind, service_events)?; } + + Ok(()) } fn service_trigger_kind( state: &mut RuntimeState, trigger_kind: u8, service_events: &mut Vec, -) { +) -> 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::>(); + let mut serviced_record_ids = Vec::new(); + let mut applied_effect_count = 0_u32; + let mut mutated_company_ids = BTreeSet::new(); + let mut appended_record_ids = Vec::new(); + let mut activated_record_ids = Vec::new(); + let mut deactivated_record_ids = Vec::new(); + let mut removed_record_ids = Vec::new(); + let mut staged_event_graph_mutations = Vec::new(); let mut dirty_rerun = false; *state @@ -159,27 +211,237 @@ fn service_trigger_kind( .entry(trigger_kind) .or_insert(0) += 1; - for record in &mut state.event_runtime_records { - if record.active && record.trigger_kind == trigger_kind { + for index in eligible_indices { + let (record_id, record_effects, record_marks_collection_dirty, record_one_shot) = { + let record = &state.event_runtime_records[index]; + ( + record.record_id, + record.effects.clone(), + record.marks_collection_dirty, + record.one_shot, + ) + }; + + let effect_summary = apply_runtime_effects( + state, + &record_effects, + &mut mutated_company_ids, + &mut staged_event_graph_mutations, + )?; + applied_effect_count += effect_summary.applied_effect_count; + appended_record_ids.extend(effect_summary.appended_record_ids); + activated_record_ids.extend(effect_summary.activated_record_ids); + deactivated_record_ids.extend(effect_summary.deactivated_record_ids); + removed_record_ids.extend(effect_summary.removed_record_ids); + + { + let record = &mut state.event_runtime_records[index]; record.service_count += 1; - serviced_record_ids.push(record.record_id); - state.service_state.total_event_record_services += 1; - if trigger_kind != 0x0a && record.marks_collection_dirty { - dirty_rerun = true; + if record_one_shot { + record.has_fired = true; } } + + serviced_record_ids.push(record_id); + state.service_state.total_event_record_services += 1; + if trigger_kind != 0x0a && record_marks_collection_dirty { + dirty_rerun = true; + } } + commit_staged_event_graph_mutations(state, &staged_event_graph_mutations)?; + service_events.push(ServiceEvent { kind: "trigger_dispatch".to_string(), trigger_kind: Some(trigger_kind), serviced_record_ids, + applied_effect_count, + mutated_company_ids: mutated_company_ids.into_iter().collect(), + appended_record_ids, + activated_record_ids, + deactivated_record_ids, + removed_record_ids, dirty_rerun, }); if dirty_rerun { state.service_state.dirty_rerun_count += 1; - service_trigger_kind(state, 0x0a, service_events); + service_trigger_kind(state, 0x0a, service_events)?; + } + + Ok(()) +} + +fn apply_runtime_effects( + state: &mut RuntimeState, + effects: &[RuntimeEffect], + mutated_company_ids: &mut BTreeSet, + staged_event_graph_mutations: &mut Vec, +) -> Result { + 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, 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::>(); + 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 { + if delta >= 0 { + current + .checked_add(delta as u64) + .ok_or_else(|| format!("company_id {company_id} debt adjustment overflow")) + } else { + current + .checked_sub(delta.unsigned_abs()) + .ok_or_else(|| format!("company_id {company_id} debt adjustment underflow")) } } @@ -189,8 +451,9 @@ mod tests { use super::*; use crate::{ - CalendarPoint, RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState, - RuntimeServiceState, RuntimeWorldRestoreState, + CalendarPoint, RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, + RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, + RuntimeWorldRestoreState, }; fn state() -> RuntimeState { @@ -267,6 +530,12 @@ mod tests { active: true, service_count: 0, marks_collection_dirty: true, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::SetWorldFlag { + key: "runtime.effect_fired".to_string(), + value: true, + }], }, RuntimeEventRecord { record_id: 2, @@ -274,6 +543,12 @@ mod tests { active: true, service_count: 0, marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::AllActive, + delta: 5, + }], }, RuntimeEventRecord { record_id: 3, @@ -281,6 +556,12 @@ mod tests { active: true, service_count: 0, marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::SetSpecialCondition { + label: "Dirty rerun fired".to_string(), + value: 1, + }], }, ], ..state() @@ -296,6 +577,9 @@ mod tests { assert_eq!(state.event_runtime_records[0].service_count, 1); assert_eq!(state.event_runtime_records[1].service_count, 1); assert_eq!(state.event_runtime_records[2].service_count, 1); + assert_eq!(state.world_flags.get("runtime.effect_fired"), Some(&true)); + assert_eq!(state.companies[0].current_cash, 15); + assert_eq!(state.special_conditions.get("Dirty rerun fired"), Some(&1)); assert_eq!( state.service_state.trigger_dispatch_counts.get(&1), Some(&1) @@ -308,5 +592,444 @@ mod tests { state.service_state.trigger_dispatch_counts.get(&0x0a), Some(&1) ); + assert_eq!(result.service_events.len(), 7); + assert_eq!(result.service_events[0].applied_effect_count, 1); + assert_eq!( + result + .service_events + .iter() + .find(|event| event.trigger_kind == Some(4)) + .expect("trigger kind 4 event should be present") + .applied_effect_count, + 1 + ); + assert_eq!( + result + .service_events + .iter() + .find(|event| event.trigger_kind == Some(0x0a)) + .expect("trigger kind 0x0a event should be present") + .applied_effect_count, + 1 + ); + assert_eq!( + result + .service_events + .iter() + .find(|event| event.trigger_kind == Some(4)) + .expect("trigger kind 4 event should be present") + .mutated_company_ids, + vec![1] + ); + } + + #[test] + fn applies_company_effects_for_specific_targets() { + let mut state = RuntimeState { + companies: vec![ + RuntimeCompany { + company_id: 1, + current_cash: 10, + debt: 5, + }, + RuntimeCompany { + company_id: 2, + current_cash: 20, + debt: 8, + }, + ], + event_runtime_records: vec![RuntimeEventRecord { + record_id: 10, + trigger_kind: 7, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![ + RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::Ids { ids: vec![2] }, + delta: 4, + }, + RuntimeEffect::AdjustCompanyDebt { + target: RuntimeCompanyTarget::Ids { ids: vec![2] }, + delta: -3, + }, + ], + }], + ..state() + }; + + let result = execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("targeted company effects should succeed"); + + assert_eq!(state.companies[0].current_cash, 10); + assert_eq!(state.companies[1].current_cash, 24); + assert_eq!(state.companies[1].debt, 5); + assert_eq!(result.service_events[0].applied_effect_count, 2); + assert_eq!(result.service_events[0].mutated_company_ids, vec![2]); + } + + #[test] + fn one_shot_record_only_fires_once() { + let mut state = RuntimeState { + event_runtime_records: vec![RuntimeEventRecord { + record_id: 20, + trigger_kind: 2, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: true, + has_fired: false, + effects: vec![RuntimeEffect::SetWorldFlag { + key: "one_shot".to_string(), + value: true, + }], + }], + ..state() + }; + + execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 2 }, + ) + .expect("first one-shot service should succeed"); + let second = execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 2 }, + ) + .expect("second one-shot service should succeed"); + + assert_eq!(state.event_runtime_records[0].service_count, 1); + assert!(state.event_runtime_records[0].has_fired); + assert_eq!( + second.service_events[0].serviced_record_ids, + Vec::::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![(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()); } } diff --git a/docs/README.md b/docs/README.md index 1776bb6..851b66c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,19 +64,23 @@ Current local tool status: ## Next Focus -The atlas milestone is broad enough that the next implementation focus shifts downward into runtime -rehosting. The highest-value next passes are: +The atlas milestone is broad enough that the next implementation focus has already shifted downward +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 - shell-first implementation bets -- stand up a bottom-up runtime core that can load state, execute deterministic world work, and dump - normalized diffs without depending on the shell controller or presentation path -- use `rrt-hook` primarily as an optional capture tool for fixtures and state probes, not as the - first execution environment -- choose early rewrite targets from the lower simulation, event-service, and persistence boundaries - before attempting shell, input, or presentation replacement -- write milestone-scoped implementation notes in `docs/runtime-rehost-plan.md` before expanding the - workspace crates +The highest-value next passes are now: + +- preserve the atlas and function map as the source of subsystem boundaries while continuing to + avoid shell-first implementation bets +- broaden the normalized event-service layer through staged event-record mutation and follow-on + record behavior +- deepen captured-runtime and round-trip fixture coverage on top of the existing runtime CLI and + fixture surfaces +- use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution + environment +- keep `docs/runtime-rehost-plan.md` current as the runtime baseline and next implementation slice + change Regenerate the initial exports with: diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 81bb3b1..b05a00c 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -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 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 Current static analysis points to one important constraint: the existing gameplay cadence is still @@ -105,7 +123,7 @@ Poor early targets: ## Milestones -### Milestone 0: Scaffolding +### Milestone 0: Scaffolding (complete) Goal: @@ -127,7 +145,7 @@ Exit criteria: - one sample fixture parses and normalizes successfully - the new crates build in the workspace -### Milestone 1: Deterministic Step Kernel +### Milestone 1: Deterministic Step Kernel (complete) Goal: @@ -148,7 +166,7 @@ Exit criteria: - 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 -### Milestone 2: Periodic Service Kernel +### Milestone 2: Periodic Service Kernel (partially complete) Goal: @@ -166,7 +184,15 @@ Exit criteria: - one fixture can execute periodic maintenance without shell state - 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: @@ -182,6 +208,13 @@ Exit criteria: - 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 Goal: @@ -259,6 +292,7 @@ Each fixture should contain: - command list - expected summary - optional expected normalized full state +- optional expected normalized state fragment when only part of the final state matters ## 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 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` -- `src/calendar.rs` -- `src/runtime.rs` -- `src/step.rs` -- `src/summary.rs` +- deterministic minimal-world stepping +- periodic boundary service +- direct trigger-service mutation +- snapshot-backed fixture execution -`crates/rrt-fixtures` +## Next Slice -- `src/lib.rs` -- `src/schema.rs` -- `src/load.rs` -- `src/normalize.rs` -- `src/diff.rs` +The recommended next implementation slice is normalized event-service breadth through staged +event-record mutation. -### 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` -- `RuntimeState` -- `StepCommand` -- `StepResult` -- `RuntimeSummary` +Public-model additions for that slice: -In `rrt-fixtures`: +- `RuntimeEventRecordTemplate` +- `RuntimeEffect::AppendEventRecord` +- `RuntimeEffect::ActivateEventRecord` +- `RuntimeEffect::DeactivateEventRecord` +- `RuntimeEffect::RemoveEventRecord` -- `FixtureDocument` -- `FixtureCommand` -- `ExpectedSummary` -- `NormalizedState` +Fixture work for that slice: -### 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: - -- `rrt-cli runtime validate-fixture ` -- `rrt-cli runtime summarize-fixture ` -- `rrt-cli runtime diff-state ` - -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. +- territory-access or selected-profile parity +- placed-structure batch placement parity +- shell queue/modal behavior +- packed RT3 event-row import/export parity diff --git a/fixtures/runtime/event-follow-on-dirty-rerun-smoke.json b/fixtures/runtime/event-follow-on-dirty-rerun-smoke.json new file mode 100644 index 0000000..403441b --- /dev/null +++ b/fixtures/runtime/event-follow-on-dirty-rerun-smoke.json @@ -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 + } + ] + } +} diff --git a/fixtures/runtime/event-record-lifecycle-smoke.json b/fixtures/runtime/event-record-lifecycle-smoke.json new file mode 100644 index 0000000..0da7dcb --- /dev/null +++ b/fixtures/runtime/event-record-lifecycle-smoke.json @@ -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 + } + ] + } +} diff --git a/fixtures/runtime/periodic-boundary-service-smoke.json b/fixtures/runtime/periodic-boundary-service-smoke.json index f297d66..9b287d0 100644 --- a/fixtures/runtime/periodic-boundary-service-smoke.json +++ b/fixtures/runtime/periodic-boundary-service-smoke.json @@ -15,23 +15,52 @@ "world_flags": { "sandbox": false }, - "companies": [], + "companies": [ + { + "company_id": 1, + "current_cash": 250000, + "debt": 1000 + } + ], "event_runtime_records": [ { "record_id": 1, "trigger_kind": 1, "active": true, - "marks_collection_dirty": true + "marks_collection_dirty": true, + "effects": [ + { + "kind": "set_world_flag", + "key": "runtime.effect_fired", + "value": true + } + ] }, { "record_id": 2, "trigger_kind": 4, - "active": true + "active": true, + "effects": [ + { + "kind": "adjust_company_cash", + "target": { + "kind": "all_active" + }, + "delta": 5 + } + ] }, { "record_id": 3, "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, "tick_slot": 0 }, - "world_flag_count": 1, - "company_count": 0, + "world_flag_count": 2, + "company_count": 1, "event_runtime_record_count": 3, "total_event_record_service_count": 3, "periodic_boundary_call_count": 1, "total_trigger_dispatch_count": 7, "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 + } } } diff --git a/fixtures/runtime/service-trigger-kind-effects-smoke.json b/fixtures/runtime/service-trigger-kind-effects-smoke.json new file mode 100644 index 0000000..c492652 --- /dev/null +++ b/fixtures/runtime/service-trigger-kind-effects-smoke.json @@ -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 + } + ] + } +}