From f918d0c4f7627b1d0d493d4fc5370725c4bd95f1 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 15 Apr 2026 09:13:51 -0700 Subject: [PATCH] Add symbolic company target runtime import --- README.md | 9 +- crates/rrt-cli/src/main.rs | 4 + crates/rrt-fixtures/src/load.rs | 3 + crates/rrt-fixtures/src/schema.rs | 30 + crates/rrt-runtime/src/import.rs | 564 ++++++++++++++++-- crates/rrt-runtime/src/lib.rs | 2 +- crates/rrt-runtime/src/persistence.rs | 1 + crates/rrt-runtime/src/runtime.rs | 74 ++- crates/rrt-runtime/src/step.rs | 216 ++++++- crates/rrt-runtime/src/summary.rs | 51 ++ docs/README.md | 15 +- docs/runtime-rehost-plan.md | 14 +- ...acked-event-parity-save-slice-fixture.json | 13 +- ...c-company-scope-overlay-base-snapshot.json | 51 ++ ...ymbolic-company-scope-overlay-fixture.json | 86 +++ ...-event-symbolic-company-scope-overlay.json | 12 + ...ent-symbolic-company-scope-save-slice.json | 165 +++++ 17 files changed, 1230 insertions(+), 80 deletions(-) create mode 100644 fixtures/runtime/packed-event-symbolic-company-scope-overlay-base-snapshot.json create mode 100644 fixtures/runtime/packed-event-symbolic-company-scope-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-symbolic-company-scope-overlay.json create mode 100644 fixtures/runtime/packed-event-symbolic-company-scope-save-slice.json diff --git a/README.md b/README.md index 4ebe436..f602bfa 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,12 @@ The long-term direction is still a DLL we can inject into the original executabl 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 current packed-event -frontier is real `0x4e9a` compact-control decode and descriptor-frontier tightening on top of the -existing save-slice, snapshot, and overlay-import workflows. The PE32 hook remains useful as -capture and integration tooling, but it is no longer the main execution milestone. +frontier is real grouped-descriptor semantic mapping on top of the existing save-slice, snapshot, +overlay-import, compact-control, and symbolic company-target workflows. The runtime already carries +selected-company and controller-role context through overlay imports so synthetic packed records can +execute `selected_company`, `human_companies`, and `ai_companies` scopes without a parallel packed +executor, while condition-relative company scopes remain explicitly blocked. The PE32 hook remains +useful as capture and integration tooling, but it is no longer the main execution milestone. ## Project Docs diff --git a/crates/rrt-cli/src/main.rs b/crates/rrt-cli/src/main.rs index 9c63004..dd7af6e 100644 --- a/crates/rrt-cli/src/main.rs +++ b/crates/rrt-cli/src/main.rs @@ -4440,6 +4440,8 @@ mod tests { .join("../../fixtures/runtime/packed-event-selective-import-save-slice-fixture.json"); let overlay_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../fixtures/runtime/packed-event-selective-import-overlay-fixture.json"); + let symbolic_overlay_fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/runtime/packed-event-symbolic-company-scope-overlay-fixture.json"); run_runtime_summarize_fixture(&parity_fixture) .expect("save-slice-backed parity fixture should summarize"); @@ -4447,6 +4449,8 @@ mod tests { .expect("save-slice-backed selective-import fixture should summarize"); run_runtime_summarize_fixture(&overlay_fixture) .expect("overlay-backed selective-import fixture should summarize"); + run_runtime_summarize_fixture(&symbolic_overlay_fixture) + .expect("overlay-backed symbolic-target fixture should summarize"); } #[test] diff --git a/crates/rrt-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index da62297..8f61f41 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -173,6 +173,7 @@ mod tests { world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), + selected_company_id: None, packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -326,9 +327,11 @@ mod tests { metadata: BTreeMap::new(), companies: vec![rrt_runtime::RuntimeCompany { company_id: 42, + controller_kind: rrt_runtime::RuntimeCompanyControllerKind::Human, current_cash: 100, debt: 0, }], + selected_company_id: Some(42), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index 3fbec75..a6a1a34 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -76,6 +76,12 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_blocked_missing_company_context_count: Option, #[serde(default)] + pub packed_event_blocked_missing_selection_context_count: Option, + #[serde(default)] + pub packed_event_blocked_missing_company_role_context_count: Option, + #[serde(default)] + pub packed_event_blocked_missing_condition_context_count: Option, + #[serde(default)] pub packed_event_blocked_missing_compact_control_count: Option, #[serde(default)] pub packed_event_blocked_unmapped_real_descriptor_count: Option, @@ -377,6 +383,30 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_missing_selection_context_count { + if actual.packed_event_blocked_missing_selection_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_selection_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_selection_context_count + )); + } + } + if let Some(count) = self.packed_event_blocked_missing_company_role_context_count { + if actual.packed_event_blocked_missing_company_role_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_company_role_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_company_role_context_count + )); + } + } + if let Some(count) = self.packed_event_blocked_missing_condition_context_count { + if actual.packed_event_blocked_missing_condition_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_condition_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_condition_context_count + )); + } + } if let Some(count) = self.packed_event_blocked_missing_compact_control_count { if actual.packed_event_blocked_missing_compact_control_count != count { mismatches.push(format!( diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 12134eb..aa7fcc1 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document}; use crate::{ - CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, + CalendarPoint, RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, + RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary, @@ -99,6 +100,47 @@ enum SaveSliceProjectionMode { Overlay, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ImportCompanyContext { + known_company_ids: BTreeSet, + selected_company_id: Option, + has_complete_controller_context: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompanyTargetImportBlocker { + MissingCompanyContext, + MissingSelectionContext, + MissingCompanyRoleContext, + MissingConditionContext, +} + +impl ImportCompanyContext { + fn standalone() -> Self { + Self { + known_company_ids: BTreeSet::new(), + selected_company_id: None, + has_complete_controller_context: false, + } + } + + fn from_runtime_state(state: &RuntimeState) -> Self { + Self { + known_company_ids: state + .companies + .iter() + .map(|company| company.company_id) + .collect(), + selected_company_id: state.selected_company_id, + has_complete_controller_context: !state.companies.is_empty() + && state + .companies + .iter() + .all(|company| company.controller_kind != RuntimeCompanyControllerKind::Unknown), + } + } +} + pub fn project_save_slice_to_runtime_state_import( save_slice: &SmpLoadedSaveSlice, import_id: &str, @@ -109,7 +151,7 @@ pub fn project_save_slice_to_runtime_state_import( } let projection = project_save_slice_components( save_slice, - &BTreeSet::new(), + &ImportCompanyContext::standalone(), SaveSliceProjectionMode::Standalone, )?; @@ -125,6 +167,7 @@ pub fn project_save_slice_to_runtime_state_import( world_restore: projection.world_restore, metadata: projection.metadata, companies: Vec::new(), + selected_company_id: None, packed_event_collection: projection.packed_event_collection, event_runtime_records: projection.event_runtime_records, candidate_availability: projection.candidate_availability, @@ -151,14 +194,10 @@ pub fn project_save_slice_overlay_to_runtime_state_import( } base_state.validate()?; - let known_company_ids = base_state - .companies - .iter() - .map(|company| company.company_id) - .collect::>(); + let company_context = ImportCompanyContext::from_runtime_state(base_state); let projection = project_save_slice_components( save_slice, - &known_company_ids, + &company_context, SaveSliceProjectionMode::Overlay, )?; @@ -177,6 +216,7 @@ pub fn project_save_slice_overlay_to_runtime_state_import( world_restore: projection.world_restore, metadata, companies: base_state.companies.clone(), + selected_company_id: base_state.selected_company_id, packed_event_collection: projection.packed_event_collection, event_runtime_records: projection.event_runtime_records, candidate_availability: projection.candidate_availability, @@ -194,7 +234,7 @@ pub fn project_save_slice_overlay_to_runtime_state_import( fn project_save_slice_components( save_slice: &SmpLoadedSaveSlice, - known_company_ids: &BTreeSet, + company_context: &ImportCompanyContext, mode: SaveSliceProjectionMode, ) -> Result { let mut world_flags = BTreeMap::new(); @@ -301,7 +341,7 @@ fn project_save_slice_components( } let (packed_event_collection, event_runtime_records) = - project_packed_event_collection(save_slice, known_company_ids)?; + project_packed_event_collection(save_slice, company_context)?; if let Some(summary) = &save_slice.event_runtime_collection { metadata.insert( "save_slice.event_runtime_collection_source_kind".to_string(), @@ -491,7 +531,7 @@ fn project_save_slice_components( fn project_packed_event_collection( save_slice: &SmpLoadedSaveSlice, - known_company_ids: &BTreeSet, + company_context: &ImportCompanyContext, ) -> Result< ( Option, @@ -506,9 +546,7 @@ fn project_packed_event_collection( let mut imported_runtime_records = Vec::new(); let mut imported_record_ids = BTreeSet::new(); for record in &summary.records { - if let Some(import_result) = - smp_packed_record_to_runtime_event_record(record, known_company_ids) - { + if let Some(import_result) = smp_packed_record_to_runtime_event_record(record, company_context) { let runtime_record = import_result?; imported_record_ids.insert(record.live_entry_id); imported_runtime_records.push(runtime_record); @@ -521,7 +559,7 @@ fn project_packed_event_collection( .map(|record| { runtime_packed_event_record_summary_from_smp( record, - known_company_ids, + company_context, imported_record_ids.contains(&record.live_entry_id), ) }) @@ -551,7 +589,7 @@ fn project_packed_event_collection( fn runtime_packed_event_record_summary_from_smp( record: &SmpLoadedPackedEventRecordSummary, - known_company_ids: &BTreeSet, + company_context: &ImportCompanyContext, imported: bool, ) -> RuntimePackedEventRecordSummary { RuntimePackedEventRecordSummary { @@ -586,11 +624,12 @@ fn runtime_packed_event_record_summary_from_smp( .iter() .map(runtime_packed_event_grouped_effect_row_summary_from_smp) .collect(), + grouped_company_targets: classify_real_grouped_company_targets(record), decoded_actions: record.decoded_actions.clone(), executable_import_ready: record.executable_import_ready, import_outcome: Some(determine_packed_event_import_outcome( record, - known_company_ids, + company_context, imported, )), notes: record.notes.clone(), @@ -661,18 +700,16 @@ fn runtime_packed_event_grouped_effect_row_summary_from_smp( fn smp_packed_record_to_runtime_event_record( record: &SmpLoadedPackedEventRecordSummary, - known_company_ids: &BTreeSet, + company_context: &ImportCompanyContext, ) -> Option> { if record.decode_status == "unsupported_framing" || record.payload_family == "real_packed_v1" { return None; } - let effects = - match smp_runtime_effects_to_runtime_effects(&record.decoded_actions, known_company_ids) { - Ok(effects) => effects, - Err(err) if err.contains("unresolved company ids") => return None, - Err(_) => return None, - }; + let effects = match smp_runtime_effects_to_runtime_effects(&record.decoded_actions, company_context) { + Ok(effects) => effects, + Err(_) => return None, + }; Some((|| { let trigger_kind = record.trigger_kind.ok_or_else(|| { @@ -713,17 +750,17 @@ fn smp_packed_record_to_runtime_event_record( fn smp_runtime_effects_to_runtime_effects( effects: &[RuntimeEffect], - known_company_ids: &BTreeSet, + company_context: &ImportCompanyContext, ) -> Result, String> { effects .iter() - .map(|effect| smp_runtime_effect_to_runtime_effect(effect, known_company_ids)) + .map(|effect| smp_runtime_effect_to_runtime_effect(effect, company_context)) .collect() } fn smp_runtime_effect_to_runtime_effect( effect: &RuntimeEffect, - known_company_ids: &BTreeSet, + company_context: &ImportCompanyContext, ) -> Result { match effect { RuntimeEffect::SetWorldFlag { key, value } => Ok(RuntimeEffect::SetWorldFlag { @@ -731,23 +768,23 @@ fn smp_runtime_effect_to_runtime_effect( value: *value, }), RuntimeEffect::AdjustCompanyCash { target, delta } => { - if company_target_supported_for_import(target, known_company_ids) { + if company_target_import_blocker(target, company_context).is_none() { Ok(RuntimeEffect::AdjustCompanyCash { target: target.clone(), delta: *delta, }) } else { - Err("packed company-cash effect requires unresolved company ids".to_string()) + Err(company_target_import_error_message(target, company_context)) } } RuntimeEffect::AdjustCompanyDebt { target, delta } => { - if company_target_supported_for_import(target, known_company_ids) { + if company_target_import_blocker(target, company_context).is_none() { Ok(RuntimeEffect::AdjustCompanyDebt { target: target.clone(), delta: *delta, }) } else { - Err("packed company-debt effect requires unresolved company ids".to_string()) + Err(company_target_import_error_message(target, company_context)) } } RuntimeEffect::SetCandidateAvailability { name, value } => { @@ -765,7 +802,7 @@ fn smp_runtime_effect_to_runtime_effect( RuntimeEffect::AppendEventRecord { record } => Ok(RuntimeEffect::AppendEventRecord { record: Box::new(smp_runtime_record_template_to_runtime( record, - known_company_ids, + company_context, )?), }), RuntimeEffect::ActivateEventRecord { record_id } => { @@ -786,7 +823,7 @@ fn smp_runtime_effect_to_runtime_effect( fn smp_runtime_record_template_to_runtime( record: &RuntimeEventRecordTemplate, - known_company_ids: &BTreeSet, + company_context: &ImportCompanyContext, ) -> Result { Ok(RuntimeEventRecordTemplate { record_id: record.record_id, @@ -794,28 +831,71 @@ fn smp_runtime_record_template_to_runtime( active: record.active, marks_collection_dirty: record.marks_collection_dirty, one_shot: record.one_shot, - effects: smp_runtime_effects_to_runtime_effects(&record.effects, known_company_ids)?, + effects: smp_runtime_effects_to_runtime_effects(&record.effects, company_context)?, }) } -fn company_target_supported_for_import( - target: &crate::RuntimeCompanyTarget, - known_company_ids: &BTreeSet, -) -> bool { +fn company_target_import_blocker( + target: &RuntimeCompanyTarget, + company_context: &ImportCompanyContext, +) -> Option { match target { - crate::RuntimeCompanyTarget::AllActive => true, - crate::RuntimeCompanyTarget::Ids { ids } => { - !ids.is_empty() - && ids + RuntimeCompanyTarget::AllActive => None, + RuntimeCompanyTarget::Ids { ids } => { + if ids.is_empty() + || ids .iter() - .all(|company_id| known_company_ids.contains(company_id)) + .any(|company_id| !company_context.known_company_ids.contains(company_id)) + { + Some(CompanyTargetImportBlocker::MissingCompanyContext) + } else { + None + } } + RuntimeCompanyTarget::HumanCompanies | RuntimeCompanyTarget::AiCompanies => { + if !company_context.has_complete_controller_context { + Some(CompanyTargetImportBlocker::MissingCompanyRoleContext) + } else { + None + } + } + RuntimeCompanyTarget::SelectedCompany => { + if company_context.selected_company_id.is_some() { + None + } else { + Some(CompanyTargetImportBlocker::MissingSelectionContext) + } + } + RuntimeCompanyTarget::ConditionTrueCompany => { + Some(CompanyTargetImportBlocker::MissingConditionContext) + } + } +} + +fn company_target_import_error_message( + target: &RuntimeCompanyTarget, + company_context: &ImportCompanyContext, +) -> String { + match company_target_import_blocker(target, company_context) { + Some(CompanyTargetImportBlocker::MissingCompanyContext) => { + "packed company effect requires resolved company ids".to_string() + } + Some(CompanyTargetImportBlocker::MissingSelectionContext) => { + "packed company effect requires selected_company_id context".to_string() + } + Some(CompanyTargetImportBlocker::MissingCompanyRoleContext) => { + "packed company effect requires company controller role context".to_string() + } + Some(CompanyTargetImportBlocker::MissingConditionContext) => { + "packed company effect requires condition-relative context".to_string() + } + None => "packed company effect is importable".to_string(), } } fn determine_packed_event_import_outcome( record: &SmpLoadedPackedEventRecordSummary, - known_company_ids: &BTreeSet, + company_context: &ImportCompanyContext, imported: bool, ) -> String { if imported { @@ -828,42 +908,105 @@ fn determine_packed_event_import_outcome( if record.compact_control.is_none() { return "blocked_missing_compact_control".to_string(); } + if let Some(blocker) = real_record_company_target_import_blocker(record, company_context) { + return company_target_import_outcome(blocker).to_string(); + } return "blocked_unmapped_real_descriptor".to_string(); } - if packed_record_requires_missing_company_context(record, known_company_ids) { - return "blocked_missing_company_context".to_string(); + if let Some(blocker) = packed_record_company_target_import_blocker(record, company_context) { + return company_target_import_outcome(blocker).to_string(); } "blocked_unsupported_decode".to_string() } -fn packed_record_requires_missing_company_context( +fn packed_record_company_target_import_blocker( record: &SmpLoadedPackedEventRecordSummary, - known_company_ids: &BTreeSet, -) -> bool { + company_context: &ImportCompanyContext, +) -> Option { record .decoded_actions .iter() - .any(|effect| runtime_effect_requires_missing_company_context(effect, known_company_ids)) + .find_map(|effect| runtime_effect_company_target_import_blocker(effect, company_context)) } -fn runtime_effect_requires_missing_company_context( +fn runtime_effect_company_target_import_blocker( effect: &RuntimeEffect, - known_company_ids: &BTreeSet, -) -> bool { + company_context: &ImportCompanyContext, +) -> Option { match effect { RuntimeEffect::AdjustCompanyCash { target, .. } | RuntimeEffect::AdjustCompanyDebt { target, .. } => { - !company_target_supported_for_import(target, known_company_ids) + company_target_import_blocker(target, company_context) } - RuntimeEffect::AppendEventRecord { record } => record.effects.iter().any(|nested| { - runtime_effect_requires_missing_company_context(nested, known_company_ids) - }), + RuntimeEffect::AppendEventRecord { record } => record + .effects + .iter() + .find_map(|nested| runtime_effect_company_target_import_blocker(nested, company_context)), RuntimeEffect::SetWorldFlag { .. } | RuntimeEffect::SetCandidateAvailability { .. } | RuntimeEffect::SetSpecialCondition { .. } | RuntimeEffect::ActivateEventRecord { .. } | RuntimeEffect::DeactivateEventRecord { .. } - | RuntimeEffect::RemoveEventRecord { .. } => false, + | RuntimeEffect::RemoveEventRecord { .. } => None, + } +} + +fn classify_real_grouped_company_targets( + record: &SmpLoadedPackedEventRecordSummary, +) -> Vec> { + let Some(control) = &record.compact_control else { + return Vec::new(); + }; + + control + .grouped_target_scope_ordinals_0x7fb + .iter() + .enumerate() + .map(|(group_index, ordinal)| { + if !record + .grouped_effect_rows + .iter() + .any(|row| row.group_index == group_index) + { + return None; + } + classify_real_grouped_company_target(*ordinal) + }) + .collect() +} + +fn classify_real_grouped_company_target(ordinal: u8) -> Option { + match ordinal { + 0 => Some(RuntimeCompanyTarget::ConditionTrueCompany), + 1 => Some(RuntimeCompanyTarget::SelectedCompany), + 2 => Some(RuntimeCompanyTarget::HumanCompanies), + 3 => Some(RuntimeCompanyTarget::AiCompanies), + _ => None, + } +} + +fn real_record_company_target_import_blocker( + record: &SmpLoadedPackedEventRecordSummary, + company_context: &ImportCompanyContext, +) -> Option { + classify_real_grouped_company_targets(record) + .into_iter() + .flatten() + .find_map(|target| company_target_import_blocker(&target, company_context)) +} + +fn company_target_import_outcome(blocker: CompanyTargetImportBlocker) -> &'static str { + match blocker { + CompanyTargetImportBlocker::MissingCompanyContext => "blocked_missing_company_context", + CompanyTargetImportBlocker::MissingSelectionContext => { + "blocked_missing_selection_context" + } + CompanyTargetImportBlocker::MissingCompanyRoleContext => { + "blocked_missing_company_role_context" + } + CompanyTargetImportBlocker::MissingConditionContext => { + "blocked_missing_condition_context" + } } } @@ -1154,6 +1297,7 @@ mod tests { world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), + selected_company_id: None, packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -1214,6 +1358,34 @@ mod tests { }] } + fn synthetic_packed_record( + record_index: usize, + live_entry_id: u32, + effect: RuntimeEffect, + ) -> crate::SmpLoadedPackedEventRecordSummary { + crate::SmpLoadedPackedEventRecordSummary { + record_index, + live_entry_id, + payload_offset: Some(0x7200 + (live_entry_id as usize * 0x20)), + payload_len: Some(64), + decode_status: "parity_only".to_string(), + payload_family: "synthetic_harness".to_string(), + trigger_kind: Some(7), + active: Some(true), + marks_collection_dirty: Some(false), + one_shot: Some(false), + compact_control: None, + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + grouped_effect_row_counts: vec![0, 0, 0, 0], + grouped_effect_rows: vec![], + decoded_actions: vec![effect], + executable_import_ready: false, + notes: vec!["synthetic test record".to_string()], + } + } + fn real_grouped_rows() -> Vec { vec![crate::SmpLoadedPackedEventGroupedEffectRowSummary { group_index: 0, @@ -1248,6 +1420,22 @@ mod tests { } } + fn real_compact_control_without_symbolic_company_scope( + ) -> crate::SmpLoadedPackedEventCompactControlSummary { + crate::SmpLoadedPackedEventCompactControlSummary { + mode_byte_0x7ef: 6, + primary_selector_0x7f0: 0x63, + grouped_mode_0x7f4: 2, + one_shot_header_0x7f5: 1, + modifier_flag_0x7f9: 1, + modifier_flag_0x7fa: 0, + grouped_target_scope_ordinals_0x7fb: vec![8, 9, 10, 11], + grouped_scope_checkboxes_0x7ff: vec![1, 0, 1, 0], + summary_toggle_0x800: 1, + grouped_territory_selectors_0x80f: vec![-1, 10, -1, 22], + } + } + #[test] fn loads_dump_document() { let text = serde_json::to_string(&RuntimeStateDumpDocument { @@ -1883,6 +2071,190 @@ mod tests { ); } + #[test] + fn classifies_symbolic_company_target_blockers_for_standalone_import() { + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 12, + live_record_count: 3, + live_entry_ids: vec![10, 11, 12], + decoded_record_count: 3, + imported_runtime_record_count: 0, + records: vec![ + synthetic_packed_record( + 0, + 10, + RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::SelectedCompany, + delta: 1, + }, + ), + synthetic_packed_record( + 1, + 11, + RuntimeEffect::AdjustCompanyDebt { + target: RuntimeCompanyTarget::HumanCompanies, + delta: 2, + }, + ), + synthetic_packed_record( + 2, + 12, + RuntimeEffect::AdjustCompanyDebt { + target: RuntimeCompanyTarget::ConditionTrueCompany, + delta: 3, + }, + ), + ], + }), + notes: vec![], + }; + + let import = project_save_slice_to_runtime_state_import( + &save_slice, + "symbolic-blockers", + None, + ) + .expect("standalone projection should succeed"); + + assert!(import.state.event_runtime_records.is_empty()); + let outcomes = import + .state + .packed_event_collection + .as_ref() + .expect("packed event collection should be present") + .records + .iter() + .map(|record| record.import_outcome.clone()) + .collect::>(); + assert_eq!( + outcomes, + vec![ + Some("blocked_missing_selection_context".to_string()), + Some("blocked_missing_company_role_context".to_string()), + Some("blocked_missing_condition_context".to_string()), + ] + ); + } + + #[test] + fn overlays_symbolic_company_targets_into_executable_runtime_records() { + let base_state = RuntimeState { + companies: vec![ + crate::RuntimeCompany { + company_id: 1, + controller_kind: RuntimeCompanyControllerKind::Human, + current_cash: 100, + debt: 10, + }, + crate::RuntimeCompany { + company_id: 2, + controller_kind: RuntimeCompanyControllerKind::Ai, + current_cash: 50, + debt: 20, + }, + ], + selected_company_id: Some(1), + ..state() + }; + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 22, + live_record_count: 2, + live_entry_ids: vec![21, 22], + decoded_record_count: 2, + imported_runtime_record_count: 0, + records: vec![ + synthetic_packed_record( + 0, + 21, + RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::SelectedCompany, + delta: 15, + }, + ), + synthetic_packed_record( + 1, + 22, + RuntimeEffect::AdjustCompanyDebt { + target: RuntimeCompanyTarget::AiCompanies, + delta: 4, + }, + ), + ], + }), + notes: vec![], + }; + + let mut import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "symbolic-overlay", + None, + ) + .expect("overlay projection should succeed"); + + assert_eq!(import.state.event_runtime_records.len(), 2); + let outcomes = import + .state + .packed_event_collection + .as_ref() + .expect("packed event collection should be present") + .records + .iter() + .map(|record| record.import_outcome.clone()) + .collect::>(); + assert_eq!( + outcomes, + vec![Some("imported".to_string()), Some("imported".to_string())] + ); + + execute_step_command( + &mut import.state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("symbolic overlay dispatch should succeed"); + + assert_eq!(import.state.companies[0].current_cash, 115); + assert_eq!(import.state.companies[1].debt, 24); + } + #[test] fn leaves_real_records_without_compact_control_blocked_missing_compact_control() { let save_slice = SmpLoadedSaveSlice { @@ -1970,7 +2342,7 @@ mod tests { } #[test] - fn leaves_real_records_with_compact_control_blocked_unmapped_real_descriptor() { + fn leaves_real_records_with_condition_relative_company_scope_blocked_missing_condition_context() { let save_slice = SmpLoadedSaveSlice { file_extension_hint: Some("gms".to_string()), container_profile_family: Some("rt3-classic-save-container-v1".to_string()), @@ -2038,6 +2410,76 @@ mod tests { .map(|control| control.mode_byte_0x7ef), Some(6) ); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .and_then(|summary| summary.records[0].import_outcome.as_deref()), + Some("blocked_missing_condition_context") + ); + } + + #[test] + fn leaves_real_records_with_unclassified_scope_blocked_unmapped_real_descriptor() { + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 7, + live_record_count: 1, + live_entry_ids: vec![7], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 7, + payload_offset: Some(0x7202), + payload_len: Some(133), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(6), + active: None, + marks_collection_dirty: None, + one_shot: Some(true), + compact_control: Some(real_compact_control_without_symbolic_company_scope()), + text_bands: packed_text_bands(), + standalone_condition_row_count: 1, + standalone_condition_rows: real_condition_rows(), + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: real_grouped_rows(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_to_runtime_state_import( + &save_slice, + "packed-events-real-descriptor-frontier", + None, + ) + .expect("save slice should project"); + + assert!(import.state.event_runtime_records.is_empty()); assert_eq!( import .state @@ -2063,9 +2505,11 @@ mod tests { metadata: BTreeMap::from([("base.note".to_string(), "kept".to_string())]), companies: vec![crate::RuntimeCompany { company_id: 42, + controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 500, debt: 20, }], + selected_company_id: Some(42), packed_event_collection: None, event_runtime_records: vec![RuntimeEventRecord { record_id: 1, @@ -2223,9 +2667,11 @@ mod tests { metadata: BTreeMap::new(), companies: vec![crate::RuntimeCompany { company_id: 42, + controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 100, debt: 0, }], + selected_company_id: Some(42), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index bcf0f13..90d8299 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -35,7 +35,7 @@ pub use pk4::{ extract_pk4_entry_bytes, extract_pk4_entry_file, inspect_pk4_bytes, inspect_pk4_file, }; pub use runtime::{ - RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, + RuntimeCompany, RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, diff --git a/crates/rrt-runtime/src/persistence.rs b/crates/rrt-runtime/src/persistence.rs index 585e34d..16830dd 100644 --- a/crates/rrt-runtime/src/persistence.rs +++ b/crates/rrt-runtime/src/persistence.rs @@ -93,6 +93,7 @@ mod tests { world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), + selected_company_id: None, packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index d7fe79b..a4cbd7b 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -4,17 +4,32 @@ use serde::{Deserialize, Serialize}; use crate::CalendarPoint; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeCompanyControllerKind { + #[default] + Unknown, + Human, + Ai, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeCompany { pub company_id: u32, pub current_cash: i64, pub debt: u64, + #[serde(default)] + pub controller_kind: RuntimeCompanyControllerKind, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RuntimeCompanyTarget { AllActive, + HumanCompanies, + AiCompanies, + SelectedCompany, + ConditionTrueCompany, Ids { ids: Vec }, } @@ -137,6 +152,8 @@ pub struct RuntimePackedEventRecordSummary { #[serde(default)] pub grouped_effect_rows: Vec, #[serde(default)] + pub grouped_company_targets: Vec>, + #[serde(default)] pub decoded_actions: Vec, #[serde(default)] pub executable_import_ready: bool, @@ -303,6 +320,8 @@ pub struct RuntimeState { #[serde(default)] pub companies: Vec, #[serde(default)] + pub selected_company_id: Option, + #[serde(default)] pub packed_event_collection: Option, #[serde(default)] pub event_runtime_records: Vec, @@ -324,6 +343,14 @@ impl RuntimeState { return Err(format!("duplicate company_id {}", company.company_id)); } } + if let Some(selected_company_id) = self.selected_company_id { + if !seen_company_ids.contains(&selected_company_id) { + return Err(format!( + "selected_company_id {} does not reference a live company", + selected_company_id + )); + } + } let mut seen_record_ids = BTreeSet::new(); for record in &self.event_runtime_records { @@ -672,7 +699,11 @@ fn validate_company_target( valid_company_ids: &BTreeSet, ) -> Result<(), String> { match target { - RuntimeCompanyTarget::AllActive => Ok(()), + RuntimeCompanyTarget::AllActive + | RuntimeCompanyTarget::HumanCompanies + | RuntimeCompanyTarget::AiCompanies + | RuntimeCompanyTarget::SelectedCompany + | RuntimeCompanyTarget::ConditionTrueCompany => Ok(()), RuntimeCompanyTarget::Ids { ids } => { if ids.is_empty() { return Err("target ids must not be empty".to_string()); @@ -709,13 +740,16 @@ mod tests { company_id: 1, current_cash: 100, debt: 0, + controller_kind: RuntimeCompanyControllerKind::Unknown, }, RuntimeCompany { company_id: 1, current_cash: 200, debt: 0, + controller_kind: RuntimeCompanyControllerKind::Unknown, }, ], + selected_company_id: None, packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -762,6 +796,7 @@ mod tests { }, metadata: BTreeMap::new(), companies: Vec::new(), + selected_company_id: None, packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -789,7 +824,9 @@ mod tests { company_id: 1, current_cash: 100, debt: 0, + controller_kind: RuntimeCompanyControllerKind::Unknown, }], + selected_company_id: None, packed_event_collection: None, event_runtime_records: vec![RuntimeEventRecord { record_id: 7, @@ -829,7 +866,9 @@ mod tests { company_id: 1, current_cash: 100, debt: 0, + controller_kind: RuntimeCompanyControllerKind::Unknown, }], + selected_company_id: None, packed_event_collection: None, event_runtime_records: vec![RuntimeEventRecord { record_id: 7, @@ -875,6 +914,7 @@ mod tests { world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), + selected_company_id: None, packed_event_collection: Some(RuntimePackedEventCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), mechanism_family: "classic-save-rehydrate-v1".to_string(), @@ -905,6 +945,7 @@ mod tests { standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), + grouped_company_targets: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: None, @@ -927,6 +968,7 @@ mod tests { standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), + grouped_company_targets: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: None, @@ -942,4 +984,34 @@ mod tests { assert!(state.validate().is_err()); } + + #[test] + fn rejects_selected_company_id_that_does_not_exist() { + 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, + controller_kind: RuntimeCompanyControllerKind::Human, + }], + selected_company_id: Some(2), + packed_event_collection: None, + event_runtime_records: Vec::new(), + 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 ea97e72..30d7cc8 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -3,7 +3,8 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use crate::{ - RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate, RuntimeState, RuntimeSummary, + RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate, + RuntimeState, RuntimeSummary, calendar::BoundaryEventKind, }; @@ -430,6 +431,49 @@ fn resolve_company_target_ids( } Ok(ids.clone()) } + RuntimeCompanyTarget::HumanCompanies => { + if state + .companies + .iter() + .any(|company| company.controller_kind == RuntimeCompanyControllerKind::Unknown) + { + return Err( + "target requires company role context but at least one company has unknown controller_kind" + .to_string(), + ); + } + Ok(state + .companies + .iter() + .filter(|company| company.controller_kind == RuntimeCompanyControllerKind::Human) + .map(|company| company.company_id) + .collect()) + } + RuntimeCompanyTarget::AiCompanies => { + if state + .companies + .iter() + .any(|company| company.controller_kind == RuntimeCompanyControllerKind::Unknown) + { + return Err( + "target requires company role context but at least one company has unknown controller_kind" + .to_string(), + ); + } + Ok(state + .companies + .iter() + .filter(|company| company.controller_kind == RuntimeCompanyControllerKind::Ai) + .map(|company| company.company_id) + .collect()) + } + RuntimeCompanyTarget::SelectedCompany => state + .selected_company_id + .map(|company_id| vec![company_id]) + .ok_or_else(|| "target requires selected_company_id context".to_string()), + RuntimeCompanyTarget::ConditionTrueCompany => { + Err("target requires condition-evaluation context".to_string()) + } } } @@ -451,9 +495,9 @@ mod tests { use super::*; use crate::{ - CalendarPoint, RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, - RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, - RuntimeWorldRestoreState, + CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimeCompanyTarget, + RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeSaveProfileState, + RuntimeServiceState, RuntimeWorldRestoreState, }; fn state() -> RuntimeState { @@ -470,9 +514,11 @@ mod tests { metadata: BTreeMap::new(), companies: vec![RuntimeCompany { company_id: 1, + controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 0, }], + selected_company_id: None, packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -630,11 +676,13 @@ mod tests { companies: vec![ RuntimeCompany { company_id: 1, + controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 5, }, RuntimeCompany { company_id: 2, + controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 20, debt: 8, }, @@ -674,6 +722,165 @@ mod tests { assert_eq!(result.service_events[0].mutated_company_ids, vec![2]); } + #[test] + fn resolves_symbolic_company_targets() { + let mut state = RuntimeState { + companies: vec![ + RuntimeCompany { + company_id: 1, + controller_kind: RuntimeCompanyControllerKind::Human, + current_cash: 10, + debt: 0, + }, + RuntimeCompany { + company_id: 2, + controller_kind: RuntimeCompanyControllerKind::Ai, + current_cash: 20, + debt: 2, + }, + ], + selected_company_id: Some(1), + event_runtime_records: vec![ + RuntimeEventRecord { + record_id: 11, + trigger_kind: 7, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::HumanCompanies, + delta: 5, + }], + }, + RuntimeEventRecord { + record_id: 12, + trigger_kind: 7, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AdjustCompanyDebt { + target: RuntimeCompanyTarget::AiCompanies, + delta: 3, + }], + }, + RuntimeEventRecord { + record_id: 13, + trigger_kind: 7, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::SelectedCompany, + delta: 7, + }], + }, + ], + ..state() + }; + + let result = execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("symbolic target effects should succeed"); + + assert_eq!(state.companies[0].current_cash, 22); + assert_eq!(state.companies[0].debt, 0); + assert_eq!(state.companies[1].current_cash, 20); + assert_eq!(state.companies[1].debt, 5); + assert_eq!(result.service_events[0].mutated_company_ids, vec![1, 2]); + } + + #[test] + fn rejects_selected_company_target_without_selection_context() { + let mut state = RuntimeState { + event_runtime_records: vec![RuntimeEventRecord { + record_id: 14, + trigger_kind: 7, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::SelectedCompany, + delta: 1, + }], + }], + ..state() + }; + + let error = execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect_err("selected company target should require selection context"); + + assert!(error.contains("selected_company_id")); + } + + #[test] + fn rejects_human_or_ai_targets_without_role_context() { + let mut state = RuntimeState { + event_runtime_records: vec![RuntimeEventRecord { + record_id: 15, + trigger_kind: 7, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::HumanCompanies, + delta: 1, + }], + }], + ..state() + }; + + let error = execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect_err("human target should require controller metadata"); + + assert!(error.contains("controller_kind")); + } + + #[test] + fn rejects_condition_true_company_target_without_condition_context() { + let mut state = RuntimeState { + event_runtime_records: vec![RuntimeEventRecord { + record_id: 16, + trigger_kind: 7, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + effects: vec![RuntimeEffect::AdjustCompanyCash { + target: RuntimeCompanyTarget::ConditionTrueCompany, + delta: 1, + }], + }], + ..state() + }; + + let error = execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect_err("condition-relative target should remain blocked"); + + assert!(error.contains("condition-evaluation context")); + } + #[test] fn one_shot_record_only_fires_once() { let mut state = RuntimeState { @@ -718,6 +925,7 @@ mod tests { let mut state = RuntimeState { companies: vec![RuntimeCompany { company_id: 1, + controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 2, }], diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index 187acd4..dcabebd 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -35,6 +35,9 @@ pub struct RuntimeSummary { pub packed_event_parity_only_record_count: usize, pub packed_event_unsupported_record_count: usize, pub packed_event_blocked_missing_company_context_count: usize, + pub packed_event_blocked_missing_selection_context_count: usize, + pub packed_event_blocked_missing_company_role_context_count: usize, + pub packed_event_blocked_missing_condition_context_count: usize, pub packed_event_blocked_missing_compact_control_count: usize, pub packed_event_blocked_unmapped_real_descriptor_count: usize, pub packed_event_blocked_structural_only_count: usize, @@ -171,6 +174,48 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_missing_selection_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_selection_context") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_missing_company_role_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_company_role_context") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_missing_condition_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_condition_context") + }) + .count() + }) + .unwrap_or(0), packed_event_blocked_missing_compact_control_count: state .packed_event_collection .as_ref() @@ -277,6 +322,7 @@ mod tests { world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), + selected_company_id: None, packed_event_collection: Some(RuntimePackedEventCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), mechanism_family: "classic-save-rehydrate-v1".to_string(), @@ -307,6 +353,7 @@ mod tests { standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), + grouped_company_targets: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: Some("blocked_missing_compact_control".to_string()), @@ -329,6 +376,7 @@ mod tests { standalone_condition_rows: Vec::new(), grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), + grouped_company_targets: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: Some("blocked_missing_company_context".to_string()), @@ -347,5 +395,8 @@ mod tests { assert_eq!(summary.packed_event_blocked_unmapped_real_descriptor_count, 0); assert_eq!(summary.packed_event_blocked_structural_only_count, 0); assert_eq!(summary.packed_event_blocked_missing_company_context_count, 1); + assert_eq!(summary.packed_event_blocked_missing_selection_context_count, 0); + assert_eq!(summary.packed_event_blocked_missing_company_role_context_count, 0); + assert_eq!(summary.packed_event_blocked_missing_condition_context_count, 0); } } diff --git a/docs/README.md b/docs/README.md index 52cf21f..b9e29ea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -75,12 +75,15 @@ 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 -- tighten the packed-event frontier from generic real-row structure into decoded real compact - control, so parity rows carry mode, selector, one-shot, and grouped target-scope state directly -- use overlay imports as the context bridge when selectively executable packed rows still need live - company state that save slices do not persist -- widen real packed-event executable coverage only after the compact-control and descriptor frontier - is stable, not just after row framing is parsed +- keep using overlay imports as the context bridge when selectively executable packed rows still + need live company state that save slices do not persist +- treat normalized symbolic company targets as the active packed-event frontier now that + `selected_company`, `human_companies`, and `ai_companies` import and execute through the runtime + service path +- widen real packed-event executable coverage only after the compact-control, symbolic target, and + descriptor frontier is stable, not just after row framing is parsed +- leave condition-relative company scopes explicit and blocked until condition evaluation has + grounded runtime semantics - keep in mind that the current local `.gms` corpus still exports with no packed event collection, so real descriptor mapping needs to stay plumbing-first until better captures exist - use `rrt-hook` primarily as optional capture or integration tooling, not as the first execution diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 9fbd745..675c1ce 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -24,10 +24,14 @@ Implemented today: - checked-in runtime fixtures already cover deterministic stepping, periodic service, direct trigger service, snapshot-backed inputs, save-slice-backed inputs, overlay-import-backed inputs, normalized state-fragment assertions, and imported packed-event execution +- overlay imports now preserve selected-company and controller-role context, and the normalized + company-target model can execute `selected_company`, `human_companies`, and `ai_companies` + symbolic scopes through the ordinary runtime service path while keeping condition-relative + company scopes explicitly blocked That means the next implementation work is breadth, not bootstrap. The recommended next slice is -real `0x4e9a` compact-control decode and descriptor-frontier tightening, not another persistence -scaffold pass. +real grouped-descriptor semantic mapping on top of the now-stable compact-control and symbolic +target frontier, not another persistence scaffold pass. ## Why This Boundary @@ -222,8 +226,10 @@ Current status: decoded actions fit the current normalized runtime-effect model - tracked save-slice documents now provide a repo-friendly captured-runtime path without checking in raw `.smp` binaries -- the remaining gap is wider packed target-family coverage plus company-import depth, not - first-pass captured-runtime plumbing +- overlay-backed captured-runtime inputs now provide enough runtime company context for symbolic + selected-company and controller-role scopes without inventing company state from save bytes alone +- the remaining gap is wider real grouped-descriptor semantic coverage plus condition evaluation, + not first-pass captured-runtime plumbing ### Milestone 4: Domain Expansion diff --git a/fixtures/runtime/packed-event-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-parity-save-slice-fixture.json index 7501a79..b841fe7 100644 --- a/fixtures/runtime/packed-event-parity-save-slice-fixture.json +++ b/fixtures/runtime/packed-event-parity-save-slice-fixture.json @@ -26,8 +26,9 @@ "packed_event_imported_runtime_record_count": 0, "packed_event_parity_only_record_count": 1, "packed_event_unsupported_record_count": 1, + "packed_event_blocked_missing_condition_context_count": 1, "packed_event_blocked_missing_compact_control_count": 0, - "packed_event_blocked_unmapped_real_descriptor_count": 1, + "packed_event_blocked_unmapped_real_descriptor_count": 0, "packed_event_blocked_structural_only_count": 0, "event_runtime_record_count": 0, "total_company_cash": 0 @@ -51,11 +52,19 @@ "payload_family": "real_packed_v1", "trigger_kind": 6, "one_shot": true, - "import_outcome": "blocked_unmapped_real_descriptor", + "import_outcome": "blocked_missing_condition_context", "compact_control": { "primary_selector_0x7f0": 99, "grouped_target_scope_ordinals_0x7fb": [0, 1, 2, 3] }, + "grouped_company_targets": [ + { + "kind": "condition_true_company" + }, + null, + null, + null + ], "standalone_condition_rows": [ { "candidate_name": "AutoPlant" diff --git a/fixtures/runtime/packed-event-symbolic-company-scope-overlay-base-snapshot.json b/fixtures/runtime/packed-event-symbolic-company-scope-overlay-base-snapshot.json new file mode 100644 index 0000000..cdf5bb4 --- /dev/null +++ b/fixtures/runtime/packed-event-symbolic-company-scope-overlay-base-snapshot.json @@ -0,0 +1,51 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-symbolic-company-scope-overlay-base-snapshot", + "source": { + "description": "Base runtime snapshot supplying selected-company and controller-role context for symbolic packed-event targets." + }, + "state": { + "calendar": { + "year": 1840, + "month_slot": 0, + "phase_slot": 1, + "tick_slot": 2 + }, + "world_flags": { + "base.only": true + }, + "metadata": { + "base.note": "symbolic target context" + }, + "companies": [ + { + "company_id": 1, + "controller_kind": "human", + "current_cash": 100, + "debt": 10 + }, + { + "company_id": 2, + "controller_kind": "ai", + "current_cash": 50, + "debt": 20 + }, + { + "company_id": 3, + "controller_kind": "human", + "current_cash": 70, + "debt": 30 + } + ], + "selected_company_id": 3, + "event_runtime_records": [], + "candidate_availability": {}, + "special_conditions": {}, + "service_state": { + "periodic_boundary_calls": 0, + "trigger_dispatch_counts": {}, + "total_event_record_services": 0, + "dirty_rerun_count": 0 + } + } +} diff --git a/fixtures/runtime/packed-event-symbolic-company-scope-overlay-fixture.json b/fixtures/runtime/packed-event-symbolic-company-scope-overlay-fixture.json new file mode 100644 index 0000000..6a4c8ba --- /dev/null +++ b/fixtures/runtime/packed-event-symbolic-company-scope-overlay-fixture.json @@ -0,0 +1,86 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-symbolic-company-scope-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture backed by an overlay import document so symbolic company-target packed events execute against selected-company and controller-role context." + }, + "state_import_path": "packed-event-symbolic-company-scope-overlay.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "calendar_projection_source": "base-snapshot-preserved", + "calendar_projection_is_placeholder": false, + "company_count": 3, + "packed_event_collection_present": true, + "packed_event_record_count": 4, + "packed_event_decoded_record_count": 4, + "packed_event_imported_runtime_record_count": 3, + "packed_event_parity_only_record_count": 1, + "packed_event_unsupported_record_count": 0, + "packed_event_blocked_missing_condition_context_count": 1, + "event_runtime_record_count": 3, + "total_event_record_service_count": 3, + "total_trigger_dispatch_count": 1, + "dirty_rerun_count": 0, + "total_company_cash": 244 + }, + "expected_state_fragment": { + "selected_company_id": 3, + "companies": [ + { + "company_id": 1, + "controller_kind": "human", + "current_cash": 100, + "debt": 14 + }, + { + "company_id": 2, + "controller_kind": "ai", + "current_cash": 59, + "debt": 20 + }, + { + "company_id": 3, + "controller_kind": "human", + "current_cash": 85, + "debt": 34 + } + ], + "packed_event_collection": { + "live_entry_ids": [21, 22, 23, 24], + "records": [ + { + "import_outcome": "imported" + }, + { + "import_outcome": "imported" + }, + { + "import_outcome": "imported" + }, + { + "import_outcome": "blocked_missing_condition_context" + } + ] + }, + "event_runtime_records": [ + { + "record_id": 21, + "service_count": 1 + }, + { + "record_id": 22, + "service_count": 1 + }, + { + "record_id": 23, + "service_count": 1 + } + ] + } +} diff --git a/fixtures/runtime/packed-event-symbolic-company-scope-overlay.json b/fixtures/runtime/packed-event-symbolic-company-scope-overlay.json new file mode 100644 index 0000000..de6a202 --- /dev/null +++ b/fixtures/runtime/packed-event-symbolic-company-scope-overlay.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "import_id": "packed-event-symbolic-company-scope-overlay", + "source": { + "description": "Overlay import that combines a selected-company snapshot with symbolic company-target packed events.", + "notes": [ + "used to prove selected, human, and ai symbolic company targets import through the normalized runtime path" + ] + }, + "base_snapshot_path": "packed-event-symbolic-company-scope-overlay-base-snapshot.json", + "save_slice_path": "packed-event-symbolic-company-scope-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-symbolic-company-scope-save-slice.json b/fixtures/runtime/packed-event-symbolic-company-scope-save-slice.json new file mode 100644 index 0000000..24abecc --- /dev/null +++ b/fixtures/runtime/packed-event-symbolic-company-scope-save-slice.json @@ -0,0 +1,165 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-symbolic-company-scope-save-slice", + "source": { + "description": "Tracked save-slice document with synthetic packed-event records that use symbolic company targets.", + "original_save_filename": "captured-symbolic-company-scope.gms", + "original_save_sha256": "symbolic-company-scope-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "locks selected-company and controller-role import behavior" + ] + }, + "save_slice": { + "file_extension_hint": "gms", + "container_profile_family": "rt3-classic-save-container-v1", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "trailer_family": null, + "bridge_family": null, + "profile": null, + "candidate_availability_table": null, + "special_conditions_table": null, + "event_runtime_collection": { + "source_kind": "packed-event-runtime-collection", + "mechanism_family": "classic-save-rehydrate-v1", + "mechanism_confidence": "grounded", + "container_profile_family": "rt3-classic-save-container-v1", + "metadata_tag_offset": 28928, + "records_tag_offset": 29184, + "close_tag_offset": 29952, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 24, + "live_record_count": 4, + "live_entry_ids": [21, 22, 23, 24], + "decoded_record_count": 4, + "imported_runtime_record_count": 3, + "records": [ + { + "record_index": 0, + "live_entry_id": 21, + "payload_offset": 29186, + "payload_len": 56, + "decode_status": "executable", + "payload_family": "synthetic_harness", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "text_bands": [], + "standalone_condition_row_count": 0, + "standalone_condition_rows": [], + "grouped_effect_row_counts": [0, 0, 0, 0], + "grouped_effect_rows": [], + "decoded_actions": [ + { + "kind": "adjust_company_cash", + "target": { + "kind": "selected_company" + }, + "delta": 15 + } + ], + "executable_import_ready": true, + "notes": [ + "selected-company symbolic target" + ] + }, + { + "record_index": 1, + "live_entry_id": 22, + "payload_offset": 29242, + "payload_len": 56, + "decode_status": "executable", + "payload_family": "synthetic_harness", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "text_bands": [], + "standalone_condition_row_count": 0, + "standalone_condition_rows": [], + "grouped_effect_row_counts": [0, 0, 0, 0], + "grouped_effect_rows": [], + "decoded_actions": [ + { + "kind": "adjust_company_debt", + "target": { + "kind": "human_companies" + }, + "delta": 4 + } + ], + "executable_import_ready": true, + "notes": [ + "human-company symbolic target" + ] + }, + { + "record_index": 2, + "live_entry_id": 23, + "payload_offset": 29298, + "payload_len": 56, + "decode_status": "executable", + "payload_family": "synthetic_harness", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "text_bands": [], + "standalone_condition_row_count": 0, + "standalone_condition_rows": [], + "grouped_effect_row_counts": [0, 0, 0, 0], + "grouped_effect_rows": [], + "decoded_actions": [ + { + "kind": "adjust_company_cash", + "target": { + "kind": "ai_companies" + }, + "delta": 9 + } + ], + "executable_import_ready": true, + "notes": [ + "ai-company symbolic target" + ] + }, + { + "record_index": 3, + "live_entry_id": 24, + "payload_offset": 29354, + "payload_len": 56, + "decode_status": "parity_only", + "payload_family": "synthetic_harness", + "trigger_kind": 7, + "active": true, + "marks_collection_dirty": false, + "one_shot": false, + "text_bands": [], + "standalone_condition_row_count": 0, + "standalone_condition_rows": [], + "grouped_effect_row_counts": [0, 0, 0, 0], + "grouped_effect_rows": [], + "decoded_actions": [ + { + "kind": "adjust_company_debt", + "target": { + "kind": "condition_true_company" + }, + "delta": 1 + } + ], + "executable_import_ready": false, + "notes": [ + "condition-relative symbolic target remains blocked" + ] + } + ] + }, + "notes": [ + "symbolic company target sample" + ] + } +}