From f73234cb998b39ab70ea83e7a9492755b33fd1ba Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 15 Apr 2026 18:27:04 -0700 Subject: [PATCH] Implement ordinary packed event conditions --- README.md | 10 +- crates/rrt-fixtures/src/load.rs | 10 +- crates/rrt-fixtures/src/schema.rs | 50 ++ crates/rrt-runtime/src/import.rs | 430 ++++++++++++++++-- crates/rrt-runtime/src/lib.rs | 15 +- crates/rrt-runtime/src/persistence.rs | 2 + crates/rrt-runtime/src/runtime.rs | 251 ++++++++++ crates/rrt-runtime/src/smp.rs | 306 ++++++++++++- crates/rrt-runtime/src/step.rs | 301 +++++++++++- crates/rrt-runtime/src/summary.rs | 65 +++ docs/README.md | 7 +- docs/runtime-rehost-plan.md | 70 +-- ...inary-company-finance-overlay-fixture.json | 93 ++++ ...vent-ordinary-company-finance-overlay.json | 9 + ...t-ordinary-company-finance-save-slice.json | 139 ++++++ ...mpany-territory-track-overlay-fixture.json | 67 +++ ...inary-company-territory-track-overlay.json | 9 + ...ry-company-territory-track-save-slice.json | 139 ++++++ ...rdinary-company-track-overlay-fixture.json | 54 +++ ...-event-ordinary-company-track-overlay.json | 9 + ...ent-ordinary-company-track-save-slice.json | 139 ++++++ ...inary-condition-overlay-base-snapshot.json | 132 ++++++ ...inary-named-territory-overlay-fixture.json | 63 +++ ...vent-ordinary-named-territory-overlay.json | 9 + ...t-ordinary-named-territory-save-slice.json | 132 ++++++ ...inary-territory-track-overlay-fixture.json | 64 +++ ...vent-ordinary-territory-track-overlay.json | 9 + ...t-ordinary-territory-track-save-slice.json | 130 ++++++ 28 files changed, 2626 insertions(+), 88 deletions(-) create mode 100644 fixtures/runtime/packed-event-ordinary-company-finance-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-ordinary-company-finance-overlay.json create mode 100644 fixtures/runtime/packed-event-ordinary-company-finance-save-slice.json create mode 100644 fixtures/runtime/packed-event-ordinary-company-territory-track-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-ordinary-company-territory-track-overlay.json create mode 100644 fixtures/runtime/packed-event-ordinary-company-territory-track-save-slice.json create mode 100644 fixtures/runtime/packed-event-ordinary-company-track-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-ordinary-company-track-overlay.json create mode 100644 fixtures/runtime/packed-event-ordinary-company-track-save-slice.json create mode 100644 fixtures/runtime/packed-event-ordinary-condition-overlay-base-snapshot.json create mode 100644 fixtures/runtime/packed-event-ordinary-named-territory-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-ordinary-named-territory-overlay.json create mode 100644 fixtures/runtime/packed-event-ordinary-named-territory-save-slice.json create mode 100644 fixtures/runtime/packed-event-ordinary-territory-track-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-ordinary-territory-track-overlay.json create mode 100644 fixtures/runtime/packed-event-ordinary-territory-track-save-slice.json diff --git a/README.md b/README.md index 7791e53..320d11f 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,12 @@ selected-company and controller-role context through overlay imports, and real d `Company Cash`, `13` `Deactivate Company`, and `16` `Company Track Pieces Buildable` now parse and execute through the ordinary runtime path. Synthetic packed records still exercise the same service engine without a parallel packed executor. The first grounded condition-side unlock now exists for -negative-sentinel `raw_condition_id = -1` company scopes, while ordinary condition-id semantics and -player/territory runtime ownership remain blocked. Mixed supported/unsupported real rows still stay -parity-only. The PE32 hook remains useful as capture and integration tooling, but it is no longer -the main execution milestone. +negative-sentinel `raw_condition_id = -1` company scopes, and the first ordinary nonnegative +condition batch now executes too: numeric-threshold company finance, company track, aggregate +territory track, and company-territory track rows can import through overlay-backed runtime +context. Named-territory bindings and player-owned condition scope still remain blocked. Mixed +supported/unsupported real rows still stay parity-only. 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-fixtures/src/load.rs b/crates/rrt-fixtures/src/load.rs index 43e5f67..ba8c095 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -137,7 +137,7 @@ mod tests { CalendarPoint, OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, RuntimeOverlayImportDocument, RuntimeOverlayImportDocumentSource, RuntimeSaveProfileState, RuntimeSaveSliceDocument, RuntimeSaveSliceDocumentSource, RuntimeServiceState, RuntimeSnapshotDocument, - RuntimeSnapshotSource, RuntimeState, RuntimeWorldRestoreState, + RuntimeSnapshotSource, RuntimeState, RuntimeTrackPieceCounts, RuntimeWorldRestoreState, SAVE_SLICE_DOCUMENT_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION, save_runtime_overlay_import_document, save_runtime_save_slice_document, save_runtime_snapshot_document, @@ -174,6 +174,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -330,10 +332,15 @@ mod tests { controller_kind: rrt_runtime::RuntimeCompanyControllerKind::Human, current_cash: 100, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], selected_company_id: Some(42), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -391,6 +398,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: vec![], + decoded_conditions: Vec::new(), decoded_actions: vec![rrt_runtime::RuntimeEffect::AdjustCompanyCash { target: rrt_runtime::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 25, diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index 9dc718e..562a837 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -64,6 +64,10 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub active_company_count: Option, #[serde(default)] + pub territory_count: Option, + #[serde(default)] + pub company_territory_track_count: Option, + #[serde(default)] pub packed_event_collection_present: Option, #[serde(default)] pub packed_event_record_count: Option, @@ -90,6 +94,12 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_blocked_territory_condition_scope_count: Option, #[serde(default)] + pub packed_event_blocked_missing_territory_context_count: Option, + #[serde(default)] + pub packed_event_blocked_named_territory_binding_count: Option, + #[serde(default)] + pub packed_event_blocked_unmapped_ordinary_condition_count: Option, + #[serde(default)] pub packed_event_blocked_missing_compact_control_count: Option, #[serde(default)] pub packed_event_blocked_unmapped_real_descriptor_count: Option, @@ -343,6 +353,22 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.territory_count { + if actual.territory_count != count { + mismatches.push(format!( + "territory_count mismatch: expected {count}, got {}", + actual.territory_count + )); + } + } + if let Some(count) = self.company_territory_track_count { + if actual.company_territory_track_count != count { + mismatches.push(format!( + "company_territory_track_count mismatch: expected {count}, got {}", + actual.company_territory_track_count + )); + } + } if let Some(present) = self.packed_event_collection_present { if actual.packed_event_collection_present != present { mismatches.push(format!( @@ -447,6 +473,30 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_missing_territory_context_count { + if actual.packed_event_blocked_missing_territory_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_territory_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_territory_context_count + )); + } + } + if let Some(count) = self.packed_event_blocked_named_territory_binding_count { + if actual.packed_event_blocked_named_territory_binding_count != count { + mismatches.push(format!( + "packed_event_blocked_named_territory_binding_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_named_territory_binding_count + )); + } + } + if let Some(count) = self.packed_event_blocked_unmapped_ordinary_condition_count { + if actual.packed_event_blocked_unmapped_ordinary_condition_count != count { + mismatches.push(format!( + "packed_event_blocked_unmapped_ordinary_condition_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_unmapped_ordinary_condition_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 716cd3a..9c1dc4a 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -6,12 +6,13 @@ use serde::{Deserialize, Serialize}; use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document}; use crate::{ CalendarPoint, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, - RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, - RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, - RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, - RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary, - RuntimePackedEventTextBandSummary, RuntimePlayerConditionTestScope, RuntimeSaveProfileState, - RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, + RuntimeCompanyTarget, RuntimeCondition, RuntimeEffect, RuntimeEventRecord, + RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, + RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, + RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, + RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, + RuntimePlayerConditionTestScope, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, + RuntimeWorldRestoreState, SmpLoadedPackedEventNegativeSentinelScopeSummary, SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice, }; @@ -106,6 +107,7 @@ struct ImportCompanyContext { known_company_ids: BTreeSet, selected_company_id: Option, has_complete_controller_context: bool, + has_territory_context: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -117,6 +119,9 @@ enum CompanyTargetImportBlocker { CompanyConditionScopeDisabled, PlayerConditionScope, TerritoryConditionScope, + MissingTerritoryContext, + NamedTerritoryBinding, + UnmappedOrdinaryCondition, } impl ImportCompanyContext { @@ -125,6 +130,7 @@ impl ImportCompanyContext { known_company_ids: BTreeSet::new(), selected_company_id: None, has_complete_controller_context: false, + has_territory_context: false, } } @@ -140,6 +146,7 @@ impl ImportCompanyContext { && state.companies.iter().all(|company| { company.controller_kind != RuntimeCompanyControllerKind::Unknown }), + has_territory_context: !state.territories.is_empty(), } } } @@ -171,6 +178,8 @@ pub fn project_save_slice_to_runtime_state_import( metadata: projection.metadata, companies: Vec::new(), selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: projection.packed_event_collection, event_runtime_records: projection.event_runtime_records, candidate_availability: projection.candidate_availability, @@ -220,6 +229,10 @@ pub fn project_save_slice_overlay_to_runtime_state_import( metadata, companies: base_state.companies.clone(), selected_company_id: base_state.selected_company_id, + territories: base_state.territories.clone(), + company_territory_track_piece_counts: base_state + .company_territory_track_piece_counts + .clone(), packed_event_collection: projection.packed_event_collection, event_runtime_records: projection.event_runtime_records, candidate_availability: projection.candidate_availability, @@ -597,8 +610,10 @@ fn runtime_packed_event_record_summary_from_smp( company_context: &ImportCompanyContext, imported: bool, ) -> RuntimePackedEventRecordSummary { - let lowered_decoded_actions = - lowered_record_decoded_actions(record).unwrap_or_else(|_| record.decoded_actions.clone()); + let lowered_decoded_conditions = lowered_record_decoded_conditions(record, company_context) + .unwrap_or_else(|_| record.decoded_conditions.clone()); + let lowered_decoded_actions = lowered_record_decoded_actions(record, company_context) + .unwrap_or_else(|_| record.decoded_actions.clone()); RuntimePackedEventRecordSummary { record_index: record.record_index, live_entry_id: record.live_entry_id, @@ -636,6 +651,7 @@ fn runtime_packed_event_record_summary_from_smp( .map(runtime_packed_event_grouped_effect_row_summary_from_smp) .collect(), grouped_company_targets: classify_real_grouped_company_targets(record), + decoded_conditions: lowered_decoded_conditions, decoded_actions: lowered_decoded_actions, executable_import_ready: record.executable_import_ready, import_outcome: Some(determine_packed_event_import_outcome( @@ -695,6 +711,11 @@ fn runtime_packed_event_condition_row_summary_from_smp( subtype: row.subtype, flag_bytes: row.flag_bytes.clone(), candidate_name: row.candidate_name.clone(), + comparator: row.comparator.clone(), + metric: row.metric.clone(), + semantic_family: row.semantic_family.clone(), + semantic_preview: row.semantic_preview.clone(), + requires_candidate_name_binding: row.requires_candidate_name_binding, notes: row.notes.clone(), } } @@ -738,11 +759,19 @@ fn smp_packed_record_to_runtime_event_record( } } - let lowered_effects = match lowered_record_decoded_actions(record) { + let lowered_conditions = match lowered_record_decoded_conditions(record, company_context) { + Ok(conditions) => conditions, + Err(_) => return None, + }; + let lowered_effects = match lowered_record_decoded_actions(record, company_context) { Ok(effects) => effects, Err(_) => return None, }; - let effects = match smp_runtime_effects_to_runtime_effects(&lowered_effects, company_context) { + let effects = match smp_runtime_effects_to_runtime_effects( + &lowered_effects, + company_context, + conditions_provide_company_context(&lowered_conditions), + ) { Ok(effects) => effects, Err(_) => return None, }; @@ -763,19 +792,42 @@ fn smp_packed_record_to_runtime_event_record( active, marks_collection_dirty, one_shot, + conditions: lowered_conditions, effects, } .into_runtime_record()) })()) } -fn lowered_record_decoded_actions( +fn lowered_record_decoded_conditions( record: &SmpLoadedPackedEventRecordSummary, -) -> Result, CompanyTargetImportBlocker> { - if let Some(blocker) = packed_record_condition_scope_import_blocker(record) { + company_context: &ImportCompanyContext, +) -> Result, CompanyTargetImportBlocker> { + if let Some(blocker) = packed_record_condition_scope_import_blocker(record, company_context) { return Err(blocker); } + let Some(lowered_target) = lowered_condition_true_company_target(record) else { + return Ok(record.decoded_conditions.clone()); + }; + Ok(record + .decoded_conditions + .iter() + .map(|condition| lower_condition_true_company_target_in_condition(condition, &lowered_target)) + .collect()) +} + +fn lowered_record_decoded_actions( + record: &SmpLoadedPackedEventRecordSummary, + company_context: &ImportCompanyContext, +) -> Result, CompanyTargetImportBlocker> { + if let Some(blocker) = packed_record_condition_scope_import_blocker(record, company_context) { + return Err(blocker); + } + + if !record.decoded_conditions.is_empty() { + return Ok(record.decoded_actions.clone()); + } let Some(lowered_target) = lowered_condition_true_company_target(record) else { return Ok(record.decoded_actions.clone()); }; @@ -788,20 +840,53 @@ fn lowered_record_decoded_actions( fn packed_record_condition_scope_import_blocker( record: &SmpLoadedPackedEventRecordSummary, + company_context: &ImportCompanyContext, ) -> Option { if record.standalone_condition_rows.is_empty() { return None; } + let ordinary_condition_row_count = record + .standalone_condition_rows + .iter() + .filter(|row| row.raw_condition_id >= 0) + .count(); + if ordinary_condition_row_count != 0 { + if record + .standalone_condition_rows + .iter() + .any(|row| row.requires_candidate_name_binding) + { + return Some(CompanyTargetImportBlocker::NamedTerritoryBinding); + } + if ordinary_condition_row_count != record.decoded_conditions.len() { + return Some(CompanyTargetImportBlocker::UnmappedOrdinaryCondition); + } + if record + .decoded_conditions + .iter() + .any(|condition| matches!(condition, RuntimeCondition::TerritoryNumericThreshold { .. } | RuntimeCondition::CompanyTerritoryNumericThreshold { .. })) + && !company_context.has_territory_context + { + return Some(CompanyTargetImportBlocker::MissingTerritoryContext); + } + } + let negative_sentinel_row_count = record .standalone_condition_rows .iter() .filter(|row| row.raw_condition_id == -1) .count(); if negative_sentinel_row_count == 0 { - return Some(CompanyTargetImportBlocker::MissingConditionContext); + return if ordinary_condition_row_count == 0 { + Some(CompanyTargetImportBlocker::MissingConditionContext) + } else { + None + }; } - if negative_sentinel_row_count != record.standalone_condition_rows.len() { + if ordinary_condition_row_count == 0 + && negative_sentinel_row_count != record.standalone_condition_rows.len() + { return Some(CompanyTargetImportBlocker::MissingConditionContext); } @@ -811,10 +896,22 @@ fn packed_record_condition_scope_import_blocker( if scope.player_test_scope != RuntimePlayerConditionTestScope::Disabled { return Some(CompanyTargetImportBlocker::PlayerConditionScope); } - if scope.territory_scope_selector_is_0x63 { + if ordinary_condition_row_count == 0 && scope.territory_scope_selector_is_0x63 { return Some(CompanyTargetImportBlocker::TerritoryConditionScope); } - if scope.company_test_scope == RuntimeCompanyConditionTestScope::Disabled { + if record.decoded_conditions.iter().any(|condition| { + matches!( + condition, + RuntimeCondition::CompanyNumericThreshold { .. } + | RuntimeCondition::CompanyTerritoryNumericThreshold { .. } + ) + }) && scope.company_test_scope == RuntimeCompanyConditionTestScope::Disabled + { + return Some(CompanyTargetImportBlocker::CompanyConditionScopeDisabled); + } + if ordinary_condition_row_count == 0 + && scope.company_test_scope == RuntimeCompanyConditionTestScope::Disabled + { return Some(CompanyTargetImportBlocker::CompanyConditionScopeDisabled); } @@ -890,6 +987,7 @@ fn lower_condition_true_company_target_in_effect( active: record.active, marks_collection_dirty: record.marks_collection_dirty, one_shot: record.one_shot, + conditions: record.conditions.clone(), effects: record .effects .iter() @@ -913,6 +1011,45 @@ fn lower_condition_true_company_target_in_effect( } } +fn lower_condition_true_company_target_in_condition( + condition: &RuntimeCondition, + lowered_target: &RuntimeCompanyTarget, +) -> RuntimeCondition { + match condition { + RuntimeCondition::CompanyNumericThreshold { + target, + metric, + comparator, + value, + } => RuntimeCondition::CompanyNumericThreshold { + target: lower_condition_true_company_target_in_company_target(target, lowered_target), + metric: *metric, + comparator: *comparator, + value: *value, + }, + RuntimeCondition::TerritoryNumericThreshold { + metric, + comparator, + value, + } => RuntimeCondition::TerritoryNumericThreshold { + metric: *metric, + comparator: *comparator, + value: *value, + }, + RuntimeCondition::CompanyTerritoryNumericThreshold { + target, + metric, + comparator, + value, + } => RuntimeCondition::CompanyTerritoryNumericThreshold { + target: lower_condition_true_company_target_in_company_target(target, lowered_target), + metric: *metric, + comparator: *comparator, + value: *value, + }, + } +} + fn lower_condition_true_company_target_in_company_target( target: &RuntimeCompanyTarget, lowered_target: &RuntimeCompanyTarget, @@ -926,16 +1063,24 @@ fn lower_condition_true_company_target_in_company_target( fn smp_runtime_effects_to_runtime_effects( effects: &[RuntimeEffect], company_context: &ImportCompanyContext, + allow_condition_true_company: bool, ) -> Result, String> { effects .iter() - .map(|effect| smp_runtime_effect_to_runtime_effect(effect, company_context)) + .map(|effect| { + smp_runtime_effect_to_runtime_effect( + effect, + company_context, + allow_condition_true_company, + ) + }) .collect() } fn smp_runtime_effect_to_runtime_effect( effect: &RuntimeEffect, company_context: &ImportCompanyContext, + allow_condition_true_company: bool, ) -> Result { match effect { RuntimeEffect::SetWorldFlag { key, value } => Ok(RuntimeEffect::SetWorldFlag { @@ -943,7 +1088,11 @@ fn smp_runtime_effect_to_runtime_effect( value: *value, }), RuntimeEffect::SetCompanyCash { target, value } => { - if company_target_import_blocker(target, company_context).is_none() { + if company_target_allowed_for_import( + target, + company_context, + allow_condition_true_company, + ) { Ok(RuntimeEffect::SetCompanyCash { target: target.clone(), value: *value, @@ -953,7 +1102,11 @@ fn smp_runtime_effect_to_runtime_effect( } } RuntimeEffect::DeactivateCompany { target } => { - if company_target_import_blocker(target, company_context).is_none() { + if company_target_allowed_for_import( + target, + company_context, + allow_condition_true_company, + ) { Ok(RuntimeEffect::DeactivateCompany { target: target.clone(), }) @@ -962,7 +1115,11 @@ fn smp_runtime_effect_to_runtime_effect( } } RuntimeEffect::SetCompanyTrackLayingCapacity { target, value } => { - if company_target_import_blocker(target, company_context).is_none() { + if company_target_allowed_for_import( + target, + company_context, + allow_condition_true_company, + ) { Ok(RuntimeEffect::SetCompanyTrackLayingCapacity { target: target.clone(), value: *value, @@ -972,7 +1129,11 @@ fn smp_runtime_effect_to_runtime_effect( } } RuntimeEffect::AdjustCompanyCash { target, delta } => { - if company_target_import_blocker(target, company_context).is_none() { + if company_target_allowed_for_import( + target, + company_context, + allow_condition_true_company, + ) { Ok(RuntimeEffect::AdjustCompanyCash { target: target.clone(), delta: *delta, @@ -982,7 +1143,11 @@ fn smp_runtime_effect_to_runtime_effect( } } RuntimeEffect::AdjustCompanyDebt { target, delta } => { - if company_target_import_blocker(target, company_context).is_none() { + if company_target_allowed_for_import( + target, + company_context, + allow_condition_true_company, + ) { Ok(RuntimeEffect::AdjustCompanyDebt { target: target.clone(), delta: *delta, @@ -1007,6 +1172,7 @@ fn smp_runtime_effect_to_runtime_effect( record: Box::new(smp_runtime_record_template_to_runtime( record, company_context, + allow_condition_true_company, )?), }), RuntimeEffect::ActivateEventRecord { record_id } => { @@ -1028,6 +1194,7 @@ fn smp_runtime_effect_to_runtime_effect( fn smp_runtime_record_template_to_runtime( record: &RuntimeEventRecordTemplate, company_context: &ImportCompanyContext, + allow_condition_true_company: bool, ) -> Result { Ok(RuntimeEventRecordTemplate { record_id: record.record_id, @@ -1035,7 +1202,39 @@ 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, company_context)?, + conditions: record.conditions.clone(), + effects: smp_runtime_effects_to_runtime_effects( + &record.effects, + company_context, + allow_condition_true_company, + )?, + }) +} + +fn company_target_allowed_for_import( + target: &RuntimeCompanyTarget, + company_context: &ImportCompanyContext, + allow_condition_true_company: bool, +) -> bool { + match company_target_import_blocker(target, company_context) { + None => true, + Some(CompanyTargetImportBlocker::MissingConditionContext) + if allow_condition_true_company + && matches!(target, RuntimeCompanyTarget::ConditionTrueCompany) => + { + true + } + Some(_) => false, + } +} + +fn conditions_provide_company_context(conditions: &[RuntimeCondition]) -> bool { + conditions.iter().any(|condition| { + matches!( + condition, + RuntimeCondition::CompanyNumericThreshold { .. } + | RuntimeCondition::CompanyTerritoryNumericThreshold { .. } + ) }) } @@ -1105,6 +1304,15 @@ fn company_target_import_error_message( "packed company effect requires territory runtime ownership for negative-sentinel scope" .to_string() } + Some(CompanyTargetImportBlocker::MissingTerritoryContext) => { + "packed condition requires territory runtime context".to_string() + } + Some(CompanyTargetImportBlocker::NamedTerritoryBinding) => { + "packed condition requires named territory binding".to_string() + } + Some(CompanyTargetImportBlocker::UnmappedOrdinaryCondition) => { + "packed ordinary condition is not yet mapped".to_string() + } None => "packed company effect is importable".to_string(), } } @@ -1125,9 +1333,18 @@ fn determine_packed_event_import_outcome( return "blocked_missing_compact_control".to_string(); } if !record.executable_import_ready { - return "blocked_unmapped_real_descriptor".to_string(); + return if record + .standalone_condition_rows + .iter() + .any(|row| row.raw_condition_id >= 0) + { + "blocked_unmapped_ordinary_condition".to_string() + } else { + "blocked_unmapped_real_descriptor".to_string() + }; } - if let Some(blocker) = packed_record_condition_scope_import_blocker(record) { + if let Some(blocker) = packed_record_condition_scope_import_blocker(record, company_context) + { return company_target_import_outcome(blocker).to_string(); } if let Some(blocker) = packed_record_company_target_import_blocker(record, company_context) @@ -1146,18 +1363,87 @@ fn packed_record_company_target_import_blocker( record: &SmpLoadedPackedEventRecordSummary, company_context: &ImportCompanyContext, ) -> Option { - let lowered_effects = match lowered_record_decoded_actions(record) { + if record + .decoded_actions + .iter() + .any(runtime_effect_uses_condition_true_company) + && !record + .decoded_conditions + .iter() + .any(|condition| matches!(condition, RuntimeCondition::CompanyNumericThreshold { .. } | RuntimeCondition::CompanyTerritoryNumericThreshold { .. })) + { + return Some(CompanyTargetImportBlocker::MissingConditionContext); + } + let lowered_conditions = match lowered_record_decoded_conditions(record, company_context) { + Ok(conditions) => conditions, + Err(blocker) => return Some(blocker), + }; + let has_company_condition_context = lowered_conditions.iter().any(|condition| { + matches!( + condition, + RuntimeCondition::CompanyNumericThreshold { .. } + | RuntimeCondition::CompanyTerritoryNumericThreshold { .. } + ) + }); + if let Some(blocker) = lowered_conditions.iter().find_map(|condition| { + runtime_condition_company_target_import_blocker(condition, company_context) + }) { + return Some(blocker); + } + let lowered_effects = match lowered_record_decoded_actions(record, company_context) { Ok(effects) => effects, Err(blocker) => return Some(blocker), }; lowered_effects .iter() - .find_map(|effect| runtime_effect_company_target_import_blocker(effect, company_context)) + .find_map(|effect| { + runtime_effect_company_target_import_blocker( + effect, + company_context, + has_company_condition_context, + ) + }) +} + +fn runtime_condition_company_target_import_blocker( + condition: &RuntimeCondition, + company_context: &ImportCompanyContext, +) -> Option { + match condition { + RuntimeCondition::CompanyNumericThreshold { target, .. } + | RuntimeCondition::CompanyTerritoryNumericThreshold { target, .. } => { + company_target_import_blocker(target, company_context) + } + RuntimeCondition::TerritoryNumericThreshold { .. } => None, + } +} + +fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool { + match effect { + RuntimeEffect::SetCompanyCash { target, .. } + | RuntimeEffect::DeactivateCompany { target } + | RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. } + | RuntimeEffect::AdjustCompanyCash { target, .. } + | RuntimeEffect::AdjustCompanyDebt { target, .. } => { + matches!(target, RuntimeCompanyTarget::ConditionTrueCompany) + } + RuntimeEffect::AppendEventRecord { record } => record + .effects + .iter() + .any(runtime_effect_uses_condition_true_company), + RuntimeEffect::SetWorldFlag { .. } + | RuntimeEffect::SetCandidateAvailability { .. } + | RuntimeEffect::SetSpecialCondition { .. } + | RuntimeEffect::ActivateEventRecord { .. } + | RuntimeEffect::DeactivateEventRecord { .. } + | RuntimeEffect::RemoveEventRecord { .. } => false, + } } fn runtime_effect_company_target_import_blocker( effect: &RuntimeEffect, company_context: &ImportCompanyContext, + allow_condition_true_company: bool, ) -> Option { match effect { RuntimeEffect::SetCompanyCash { target, .. } @@ -1165,10 +1451,19 @@ fn runtime_effect_company_target_import_blocker( | RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. } | RuntimeEffect::AdjustCompanyCash { target, .. } | RuntimeEffect::AdjustCompanyDebt { target, .. } => { + if allow_condition_true_company + && matches!(target, RuntimeCompanyTarget::ConditionTrueCompany) + { + return None; + } company_target_import_blocker(target, company_context) } RuntimeEffect::AppendEventRecord { record } => record.effects.iter().find_map(|nested| { - runtime_effect_company_target_import_blocker(nested, company_context) + runtime_effect_company_target_import_blocker( + nested, + company_context, + allow_condition_true_company, + ) }), RuntimeEffect::SetWorldFlag { .. } | RuntimeEffect::SetCandidateAvailability { .. } @@ -1226,6 +1521,11 @@ fn company_target_import_outcome(blocker: CompanyTargetImportBlocker) -> &'stati } CompanyTargetImportBlocker::PlayerConditionScope => "blocked_player_condition_scope", CompanyTargetImportBlocker::TerritoryConditionScope => "blocked_territory_condition_scope", + CompanyTargetImportBlocker::MissingTerritoryContext => "blocked_missing_territory_context", + CompanyTargetImportBlocker::NamedTerritoryBinding => "blocked_named_territory_binding", + CompanyTargetImportBlocker::UnmappedOrdinaryCondition => { + "blocked_unmapped_ordinary_condition" + } } } @@ -1501,7 +1801,7 @@ fn resolve_document_path(base_dir: &Path, path: &str) -> PathBuf { #[cfg(test)] mod tests { use super::*; - use crate::{StepCommand, execute_step_command}; + use crate::{RuntimeTrackPieceCounts, StepCommand, execute_step_command}; fn state() -> RuntimeState { RuntimeState { @@ -1517,6 +1817,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -1573,6 +1875,11 @@ mod tests { subtype: 4, flag_bytes: vec![0x30; 25], candidate_name: Some("AutoPlant".to_string()), + comparator: None, + metric: None, + semantic_family: None, + semantic_preview: None, + requires_candidate_name_binding: false, notes: vec!["negative sentinel-style condition row id".to_string()], }] } @@ -1600,6 +1907,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: vec![], + decoded_conditions: Vec::new(), decoded_actions: vec![effect], executable_import_ready: false, notes: vec!["synthetic test record".to_string()], @@ -1996,6 +2304,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, notes: vec!["test".to_string()], @@ -2018,6 +2327,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, notes: vec!["test".to_string()], @@ -2040,6 +2350,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, notes: vec!["test".to_string()], @@ -2252,6 +2563,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 1, 0, 0], grouped_effect_rows: vec![], + decoded_conditions: Vec::new(), decoded_actions: vec![ RuntimeEffect::SetWorldFlag { key: "from_packed_root".to_string(), @@ -2264,6 +2576,7 @@ mod tests { active: true, marks_collection_dirty: false, one_shot: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetSpecialCondition { label: "Imported Follow-On".to_string(), value: 1, @@ -2363,6 +2676,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: vec![], + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::AdjustCompanyCash { target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 50, @@ -2498,6 +2812,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 100, debt: 10, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -2506,6 +2823,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 50, debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -2640,6 +2960,7 @@ mod tests { )), grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 7, @@ -2694,6 +3015,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 100, debt: 10, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -2702,6 +3026,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 50, debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -2710,6 +3037,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 70, debt: 30, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -2763,6 +3093,7 @@ mod tests { )), grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 7, @@ -2790,6 +3121,7 @@ mod tests { )), grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 8, @@ -2817,6 +3149,7 @@ mod tests { )), grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 9, @@ -2844,6 +3177,7 @@ mod tests { )), grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 10, @@ -2871,6 +3205,7 @@ mod tests { )), grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 11, @@ -2995,6 +3330,7 @@ mod tests { negative_sentinel_scope: Some(player_negative_sentinel_scope()), grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 7, @@ -3069,6 +3405,7 @@ mod tests { negative_sentinel_scope: Some(territory_negative_sentinel_scope()), grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 7, @@ -3143,6 +3480,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: real_grouped_rows(), + decoded_conditions: Vec::new(), decoded_actions: vec![], executable_import_ready: false, notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], @@ -3187,10 +3525,15 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 500, debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], selected_company_id: Some(42), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -3275,6 +3618,7 @@ mod tests { "grouped effect row carries locomotive-name side string".to_string(), ], }], + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::SelectedCompany, value: 250, @@ -3324,6 +3668,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 500, debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], @@ -3384,6 +3731,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: vec![real_deactivate_company_row(true)], + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::DeactivateCompany { target: RuntimeCompanyTarget::SelectedCompany, }], @@ -3469,6 +3817,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: vec![real_deactivate_company_row(false)], + decoded_conditions: Vec::new(), decoded_actions: vec![], executable_import_ready: false, notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], @@ -3503,6 +3852,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 500, debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], @@ -3563,6 +3915,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![1, 0, 0, 0], grouped_effect_rows: vec![real_track_capacity_row(18)], + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyTrackLayingCapacity { target: RuntimeCompanyTarget::SelectedCompany, value: Some(18), @@ -3602,6 +3955,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 500, debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], @@ -3665,6 +4021,7 @@ mod tests { real_track_capacity_row(18), unsupported_real_grouped_row(), ], + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::SetCompanyTrackLayingCapacity { target: RuntimeCompanyTarget::SelectedCompany, value: Some(18), @@ -3713,10 +4070,15 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 500, debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], selected_company_id: Some(42), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: vec![RuntimeEventRecord { record_id: 1, @@ -3726,6 +4088,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![], }], candidate_availability: BTreeMap::new(), @@ -3780,6 +4143,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: vec![], + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::AdjustCompanyCash { target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 50, @@ -3878,10 +4242,15 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 100, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], selected_company_id: Some(42), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -3939,6 +4308,7 @@ mod tests { negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: vec![], + decoded_conditions: Vec::new(), decoded_actions: vec![RuntimeEffect::AdjustCompanyCash { target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 50, diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index ffbff9c..680d529 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -36,12 +36,15 @@ pub use pk4::{ }; pub use runtime::{ RuntimeCompany, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, - RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, - RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, - RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, - RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary, - RuntimePackedEventTextBandSummary, RuntimePlayerConditionTestScope, RuntimeSaveProfileState, - RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, + RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCompanyTerritoryTrackPieceCount, + RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecord, + RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, + RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, + RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, + RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, + RuntimePlayerConditionTestScope, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, + RuntimeTerritory, RuntimeTerritoryMetric, RuntimeTrackMetric, RuntimeTrackPieceCounts, + RuntimeWorldRestoreState, }; pub use smp::{ SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION, SmpAlignedRuntimeRuleBandLane, diff --git a/crates/rrt-runtime/src/persistence.rs b/crates/rrt-runtime/src/persistence.rs index 16830dd..0dd3569 100644 --- a/crates/rrt-runtime/src/persistence.rs +++ b/crates/rrt-runtime/src/persistence.rs @@ -94,6 +94,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), 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 59946e7..f26b205 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -22,12 +22,49 @@ pub struct RuntimeCompany { pub company_id: u32, pub current_cash: i64, pub debt: u64, + #[serde(default)] + pub credit_rating_score: Option, + #[serde(default)] + pub prime_rate: Option, #[serde(default = "runtime_company_default_active")] pub active: bool, #[serde(default)] pub available_track_laying_capacity: Option, #[serde(default)] pub controller_kind: RuntimeCompanyControllerKind, + #[serde(default)] + pub track_piece_counts: RuntimeTrackPieceCounts, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RuntimeTrackPieceCounts { + #[serde(default)] + pub total: u32, + #[serde(default)] + pub single: u32, + #[serde(default)] + pub double: u32, + #[serde(default)] + pub transition: u32, + #[serde(default)] + pub electric: u32, + #[serde(default)] + pub non_electric: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeTerritory { + pub territory_id: u32, + #[serde(default)] + pub track_piece_counts: RuntimeTrackPieceCounts, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeCompanyTerritoryTrackPieceCount { + pub company_id: u32, + pub territory_id: u32, + #[serde(default)] + pub track_piece_counts: RuntimeTrackPieceCounts, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -63,6 +100,76 @@ pub enum RuntimePlayerConditionTestScope { HumanPlayersOnly, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeConditionComparator { + Ge, + Le, + Gt, + Lt, + Eq, + Ne, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeCompanyMetric { + CurrentCash, + TotalDebt, + CreditRating, + PrimeRate, + TrackPiecesTotal, + TrackPiecesSingle, + TrackPiecesDouble, + TrackPiecesTransition, + TrackPiecesElectric, + TrackPiecesNonElectric, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTerritoryMetric { + TrackPiecesTotal, + TrackPiecesSingle, + TrackPiecesDouble, + TrackPiecesTransition, + TrackPiecesElectric, + TrackPiecesNonElectric, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTrackMetric { + Total, + Single, + Double, + Transition, + Electric, + NonElectric, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuntimeCondition { + CompanyNumericThreshold { + target: RuntimeCompanyTarget, + metric: RuntimeCompanyMetric, + comparator: RuntimeConditionComparator, + value: i64, + }, + TerritoryNumericThreshold { + metric: RuntimeTerritoryMetric, + comparator: RuntimeConditionComparator, + value: i64, + }, + CompanyTerritoryNumericThreshold { + target: RuntimeCompanyTarget, + metric: RuntimeTrackMetric, + comparator: RuntimeConditionComparator, + value: i64, + }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RuntimeEffect { @@ -121,6 +228,8 @@ pub struct RuntimeEventRecordTemplate { #[serde(default)] pub one_shot: bool, #[serde(default)] + pub conditions: Vec, + #[serde(default)] pub effects: Vec, } @@ -138,6 +247,8 @@ pub struct RuntimeEventRecord { #[serde(default)] pub has_fired: bool, #[serde(default)] + pub conditions: Vec, + #[serde(default)] pub effects: Vec, } @@ -197,6 +308,8 @@ pub struct RuntimePackedEventRecordSummary { #[serde(default)] pub grouped_company_targets: Vec>, #[serde(default)] + pub decoded_conditions: Vec, + #[serde(default)] pub decoded_actions: Vec, #[serde(default)] pub executable_import_ready: bool, @@ -250,6 +363,16 @@ pub struct RuntimePackedEventConditionRowSummary { #[serde(default)] pub candidate_name: Option, #[serde(default)] + pub comparator: Option, + #[serde(default)] + pub metric: Option, + #[serde(default)] + pub semantic_family: Option, + #[serde(default)] + pub semantic_preview: Option, + #[serde(default)] + pub requires_candidate_name_binding: bool, + #[serde(default)] pub notes: Vec, } @@ -293,6 +416,7 @@ impl RuntimeEventRecordTemplate { marks_collection_dirty: self.marks_collection_dirty, one_shot: self.one_shot, has_fired: false, + conditions: self.conditions, effects: self.effects, } } @@ -384,6 +508,10 @@ pub struct RuntimeState { #[serde(default)] pub selected_company_id: Option, #[serde(default)] + pub territories: Vec, + #[serde(default)] + pub company_territory_track_piece_counts: Vec, + #[serde(default)] pub packed_event_collection: Option, #[serde(default)] pub event_runtime_records: Vec, @@ -424,11 +552,40 @@ impl RuntimeState { } } + let mut seen_territory_ids = BTreeSet::new(); + for territory in &self.territories { + if !seen_territory_ids.insert(territory.territory_id) { + return Err(format!("duplicate territory_id {}", territory.territory_id)); + } + } + for entry in &self.company_territory_track_piece_counts { + if !seen_company_ids.contains(&entry.company_id) { + return Err(format!( + "company_territory_track_piece_counts references unknown company_id {}", + entry.company_id + )); + } + if !seen_territory_ids.contains(&entry.territory_id) { + return Err(format!( + "company_territory_track_piece_counts references unknown territory_id {}", + entry.territory_id + )); + } + } + let mut seen_record_ids = BTreeSet::new(); for record in &self.event_runtime_records { if !seen_record_ids.insert(record.record_id) { return Err(format!("duplicate record_id {}", record.record_id)); } + for (condition_index, condition) in record.conditions.iter().enumerate() { + validate_runtime_condition(condition, &seen_company_ids).map_err(|err| { + format!( + "event_runtime_records[record_id={}].conditions[{condition_index}] {err}", + record.record_id + ) + })?; + } for (effect_index, effect) in record.effects.iter().enumerate() { validate_runtime_effect(effect, &seen_company_ids).map_err(|err| { format!( @@ -613,6 +770,42 @@ impl RuntimeState { "packed_event_collection.records[{record_index}].standalone_condition_rows contains an empty candidate_name" )); } + if row + .comparator + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(format!( + "packed_event_collection.records[{record_index}].standalone_condition_rows contains an empty comparator" + )); + } + if row + .metric + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(format!( + "packed_event_collection.records[{record_index}].standalone_condition_rows contains an empty metric" + )); + } + if row + .semantic_family + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(format!( + "packed_event_collection.records[{record_index}].standalone_condition_rows contains an empty semantic_family" + )); + } + if row + .semantic_preview + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(format!( + "packed_event_collection.records[{record_index}].standalone_condition_rows contains an empty semantic_preview" + )); + } } for row in &record.grouped_effect_rows { if row.row_shape.trim().is_empty() { @@ -758,6 +951,14 @@ fn validate_event_record_template( record: &RuntimeEventRecordTemplate, valid_company_ids: &BTreeSet, ) -> Result<(), String> { + for (condition_index, condition) in record.conditions.iter().enumerate() { + validate_runtime_condition(condition, valid_company_ids).map_err(|err| { + format!( + "template record_id={}.conditions[{condition_index}] {err}", + record.record_id + ) + })?; + } for (effect_index, effect) in record.effects.iter().enumerate() { validate_runtime_effect(effect, valid_company_ids).map_err(|err| { format!( @@ -770,6 +971,19 @@ fn validate_event_record_template( Ok(()) } +fn validate_runtime_condition( + condition: &RuntimeCondition, + valid_company_ids: &BTreeSet, +) -> Result<(), String> { + match condition { + RuntimeCondition::CompanyNumericThreshold { target, .. } + | RuntimeCondition::CompanyTerritoryNumericThreshold { target, .. } => { + validate_company_target(target, valid_company_ids) + } + RuntimeCondition::TerritoryNumericThreshold { .. } => Ok(()), + } +} + fn validate_company_target( target: &RuntimeCompanyTarget, valid_company_ids: &BTreeSet, @@ -816,6 +1030,9 @@ mod tests { company_id: 1, current_cash: 100, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, controller_kind: RuntimeCompanyControllerKind::Unknown, @@ -824,12 +1041,17 @@ mod tests { company_id: 1, current_cash: 200, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, controller_kind: RuntimeCompanyControllerKind::Unknown, }, ], selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -877,6 +1099,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -904,11 +1128,16 @@ mod tests { company_id: 1, current_cash: 100, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, controller_kind: RuntimeCompanyControllerKind::Unknown, }], selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: vec![RuntimeEventRecord { record_id: 7, @@ -918,6 +1147,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::Ids { ids: vec![2] }, delta: 50, @@ -948,11 +1178,16 @@ mod tests { company_id: 1, current_cash: 100, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, controller_kind: RuntimeCompanyControllerKind::Unknown, }], selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: vec![RuntimeEventRecord { record_id: 7, @@ -962,6 +1197,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 8, @@ -969,6 +1205,7 @@ mod tests { active: true, marks_collection_dirty: false, one_shot: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::Ids { ids: vec![2] }, delta: 50, @@ -999,6 +1236,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: Some(RuntimePackedEventCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), mechanism_family: "classic-save-rehydrate-v1".to_string(), @@ -1031,6 +1270,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), grouped_company_targets: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: None, @@ -1055,6 +1295,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), grouped_company_targets: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: None, @@ -1088,11 +1329,16 @@ mod tests { company_id: 1, current_cash: 100, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, controller_kind: RuntimeCompanyControllerKind::Human, }], selected_company_id: Some(2), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -1120,11 +1366,16 @@ mod tests { company_id: 1, current_cash: 100, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: false, available_track_laying_capacity: None, controller_kind: RuntimeCompanyControllerKind::Human, }], selected_company_id: Some(1), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index f762794..a2c4e01 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -5,8 +5,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - RuntimeCompanyConditionTestScope, RuntimeCompanyTarget, RuntimeEffect, - RuntimeEventRecordTemplate, RuntimePlayerConditionTestScope, + RuntimeCompanyConditionTestScope, RuntimeCompanyMetric, RuntimeCompanyTarget, + RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, + RuntimePlayerConditionTestScope, RuntimeTerritoryMetric, RuntimeTrackMetric, }; pub const SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION: u32 = 0x03ec; @@ -184,6 +185,133 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad }, ]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RealOrdinaryConditionMetric { + Company(RuntimeCompanyMetric), + Territory(RuntimeTerritoryMetric), + CompanyTerritory(RuntimeTrackMetric), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RealOrdinaryConditionMetadata { + raw_condition_id: i32, + label: &'static str, + metric: RealOrdinaryConditionMetric, +} + +const REAL_ORDINARY_CONDITION_METADATA: [RealOrdinaryConditionMetadata; 22] = [ + RealOrdinaryConditionMetadata { + raw_condition_id: 1802, + label: "Current Cash", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::CurrentCash), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 951, + label: "Total Debt", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::TotalDebt), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2366, + label: "Credit Rating", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::CreditRating), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2368, + label: "Prime Rate", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::PrimeRate), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2293, + label: "Company Track Pieces", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::TrackPiecesTotal), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2294, + label: "Company Single Track Pieces", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::TrackPiecesSingle), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2295, + label: "Company Double Track Pieces", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::TrackPiecesDouble), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2296, + label: "Company Transition Track Pieces", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::TrackPiecesTransition), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2297, + label: "Company Electric Track Pieces", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::TrackPiecesElectric), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2298, + label: "Company Non-Electric Track Pieces", + metric: RealOrdinaryConditionMetric::Company(RuntimeCompanyMetric::TrackPiecesNonElectric), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2313, + label: "Territory Track Pieces", + metric: RealOrdinaryConditionMetric::Territory(RuntimeTerritoryMetric::TrackPiecesTotal), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2314, + label: "Territory Single Track Pieces", + metric: RealOrdinaryConditionMetric::Territory(RuntimeTerritoryMetric::TrackPiecesSingle), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2315, + label: "Territory Double Track Pieces", + metric: RealOrdinaryConditionMetric::Territory(RuntimeTerritoryMetric::TrackPiecesDouble), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2316, + label: "Territory Transition Track Pieces", + metric: RealOrdinaryConditionMetric::Territory(RuntimeTerritoryMetric::TrackPiecesTransition), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2317, + label: "Territory Electric Track Pieces", + metric: RealOrdinaryConditionMetric::Territory(RuntimeTerritoryMetric::TrackPiecesElectric), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2318, + label: "Territory Non-Electric Track Pieces", + metric: RealOrdinaryConditionMetric::Territory(RuntimeTerritoryMetric::TrackPiecesNonElectric), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2323, + label: "Company-Territory Track Pieces", + metric: RealOrdinaryConditionMetric::CompanyTerritory(RuntimeTrackMetric::Total), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2324, + label: "Company-Territory Single Track Pieces", + metric: RealOrdinaryConditionMetric::CompanyTerritory(RuntimeTrackMetric::Single), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2325, + label: "Company-Territory Double Track Pieces", + metric: RealOrdinaryConditionMetric::CompanyTerritory(RuntimeTrackMetric::Double), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2326, + label: "Company-Territory Transition Track Pieces", + metric: RealOrdinaryConditionMetric::CompanyTerritory(RuntimeTrackMetric::Transition), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2327, + label: "Company-Territory Electric Track Pieces", + metric: RealOrdinaryConditionMetric::CompanyTerritory(RuntimeTrackMetric::Electric), + }, + RealOrdinaryConditionMetadata { + raw_condition_id: 2328, + label: "Company-Territory Non-Electric Track Pieces", + metric: RealOrdinaryConditionMetric::CompanyTerritory(RuntimeTrackMetric::NonElectric), + }, +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct KnownSpecialConditionDefinition { slot_index: u8, @@ -1321,6 +1449,8 @@ pub struct SmpLoadedPackedEventRecordSummary { #[serde(default)] pub grouped_effect_rows: Vec, #[serde(default)] + pub decoded_conditions: Vec, + #[serde(default)] pub decoded_actions: Vec, #[serde(default)] pub executable_import_ready: bool, @@ -1369,6 +1499,16 @@ pub struct SmpLoadedPackedEventConditionRowSummary { #[serde(default)] pub candidate_name: Option, #[serde(default)] + pub comparator: Option, + #[serde(default)] + pub metric: Option, + #[serde(default)] + pub semantic_family: Option, + #[serde(default)] + pub semantic_preview: Option, + #[serde(default)] + pub requires_candidate_name_binding: bool, + #[serde(default)] pub notes: Vec, } @@ -1853,6 +1993,7 @@ fn parse_synthetic_event_runtime_record_summary( negative_sentinel_scope: None, grouped_effect_row_counts, grouped_effect_rows: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions, executable_import_ready, notes: vec!["decoded from the current synthetic packed-event record harness".to_string()], @@ -1958,15 +2099,27 @@ fn parse_real_event_runtime_record_summary( let negative_sentinel_scope = compact_control.as_ref().and_then(|control| { derive_negative_sentinel_scope_summary(&standalone_condition_rows, control) }); + let decoded_conditions = decode_real_condition_rows( + &standalone_condition_rows, + negative_sentinel_scope.as_ref(), + ); let decoded_actions = compact_control .as_ref() .map(|control| decode_real_grouped_effect_actions(&grouped_effect_rows, control)) .unwrap_or_default(); + let ordinary_condition_row_count = standalone_condition_rows + .iter() + .filter(|row| row.raw_condition_id >= 0) + .count(); let executable_import_ready = !grouped_effect_rows.is_empty() && decoded_actions.len() == grouped_effect_rows.len() + && decoded_conditions.len() == ordinary_condition_row_count && decoded_actions .iter() - .all(runtime_effect_supported_for_save_import); + .all(runtime_effect_supported_for_save_import) + && decoded_conditions + .iter() + .all(runtime_condition_supported_for_save_import); let consumed_len = cursor; Some(( SmpLoadedPackedEventRecordSummary { @@ -1989,6 +2142,7 @@ fn parse_real_event_runtime_record_summary( negative_sentinel_scope, grouped_effect_row_counts, grouped_effect_rows, + decoded_conditions, decoded_actions, executable_import_ready, notes: vec![ @@ -2074,6 +2228,22 @@ fn parse_real_condition_row_summary( ) -> Option { let raw_condition_id = read_u32_at(row_bytes, 0)? as i32; let subtype = read_u8_at(row_bytes, 4)?; + let flag_bytes = row_bytes + .get(5..PACKED_EVENT_REAL_CONDITION_ROW_LEN)? + .to_vec(); + let ordinary_metadata = real_ordinary_condition_metadata(raw_condition_id); + let comparator = ordinary_metadata + .and_then(|_| decode_real_condition_comparator(subtype)) + .map(condition_comparator_label); + let metric = ordinary_metadata.map(|metadata| metadata.label.to_string()); + let threshold = ordinary_metadata.and_then(|_| decode_real_condition_threshold(&flag_bytes)); + let requires_candidate_name_binding = ordinary_metadata.is_some_and(|metadata| { + matches!( + metadata.metric, + RealOrdinaryConditionMetric::Territory(_) + | RealOrdinaryConditionMetric::CompanyTerritory(_) + ) && candidate_name.is_some() + }); let mut notes = Vec::new(); if raw_condition_id < 0 { notes.push("negative sentinel-style condition row id".to_string()); @@ -2081,14 +2251,27 @@ fn parse_real_condition_row_summary( if candidate_name.is_some() { notes.push("condition row carries candidate-name side string".to_string()); } + if ordinary_metadata.is_none() && raw_condition_id >= 0 { + notes.push("ordinary condition id is not yet recovered in the checked-in condition table".to_string()); + } Some(SmpLoadedPackedEventConditionRowSummary { row_index, raw_condition_id, subtype, - flag_bytes: row_bytes - .get(5..PACKED_EVENT_REAL_CONDITION_ROW_LEN)? - .to_vec(), + flag_bytes, candidate_name, + comparator, + metric, + semantic_family: ordinary_metadata.map(|_| "numeric_threshold".to_string()), + semantic_preview: ordinary_metadata.and_then(|metadata| { + threshold.map(|value| { + let comparator_text = decode_real_condition_comparator(subtype) + .map(condition_comparator_symbol) + .unwrap_or("?"); + format!("Test {} {} {}", metadata.label, comparator_text, value) + }) + }), + requires_candidate_name_binding, notes, }) } @@ -2136,6 +2319,56 @@ fn decode_player_condition_test_scope(value: u8) -> Option Option { + REAL_ORDINARY_CONDITION_METADATA + .iter() + .copied() + .find(|metadata| metadata.raw_condition_id == raw_condition_id) +} + +fn decode_real_condition_comparator(subtype: u8) -> Option { + match subtype { + 0 => Some(RuntimeConditionComparator::Ge), + 1 => Some(RuntimeConditionComparator::Le), + 2 => Some(RuntimeConditionComparator::Gt), + 3 => Some(RuntimeConditionComparator::Lt), + 4 => Some(RuntimeConditionComparator::Eq), + 5 => Some(RuntimeConditionComparator::Ne), + _ => None, + } +} + +fn decode_real_condition_threshold(flag_bytes: &[u8]) -> Option { + let raw = flag_bytes.get(0..4)?; + let mut bytes = [0u8; 4]; + bytes.copy_from_slice(raw); + Some(i32::from_le_bytes(bytes).into()) +} + +fn condition_comparator_label(comparator: RuntimeConditionComparator) -> String { + match comparator { + RuntimeConditionComparator::Ge => "ge".to_string(), + RuntimeConditionComparator::Le => "le".to_string(), + RuntimeConditionComparator::Gt => "gt".to_string(), + RuntimeConditionComparator::Lt => "lt".to_string(), + RuntimeConditionComparator::Eq => "eq".to_string(), + RuntimeConditionComparator::Ne => "ne".to_string(), + } +} + +fn condition_comparator_symbol(comparator: RuntimeConditionComparator) -> &'static str { + match comparator { + RuntimeConditionComparator::Ge => ">=", + RuntimeConditionComparator::Le => "<=", + RuntimeConditionComparator::Gt => ">", + RuntimeConditionComparator::Lt => "<", + RuntimeConditionComparator::Eq => "==", + RuntimeConditionComparator::Ne => "!=", + } +} + fn parse_real_grouped_effect_row_summary( row_bytes: &[u8], group_index: usize, @@ -2210,6 +2443,52 @@ fn parse_real_grouped_effect_row_summary( }) } +fn decode_real_condition_rows( + rows: &[SmpLoadedPackedEventConditionRowSummary], + negative_sentinel_scope: Option<&SmpLoadedPackedEventNegativeSentinelScopeSummary>, +) -> Vec { + rows.iter() + .filter(|row| row.raw_condition_id >= 0) + .filter_map(|row| decode_real_condition_row(row, negative_sentinel_scope)) + .collect() +} + +fn decode_real_condition_row( + row: &SmpLoadedPackedEventConditionRowSummary, + negative_sentinel_scope: Option<&SmpLoadedPackedEventNegativeSentinelScopeSummary>, +) -> Option { + let metadata = real_ordinary_condition_metadata(row.raw_condition_id)?; + let comparator = decode_real_condition_comparator(row.subtype)?; + let value = decode_real_condition_threshold(&row.flag_bytes)?; + match metadata.metric { + RealOrdinaryConditionMetric::Company(metric) => Some(RuntimeCondition::CompanyNumericThreshold { + target: RuntimeCompanyTarget::ConditionTrueCompany, + metric, + comparator, + value, + }), + RealOrdinaryConditionMetric::Territory(metric) => { + negative_sentinel_scope + .filter(|scope| scope.territory_scope_selector_is_0x63) + .map(|_| RuntimeCondition::TerritoryNumericThreshold { + metric, + comparator, + value, + }) + } + RealOrdinaryConditionMetric::CompanyTerritory(metric) => { + negative_sentinel_scope + .filter(|scope| scope.territory_scope_selector_is_0x63) + .map(|_| RuntimeCondition::CompanyTerritoryNumericThreshold { + target: RuntimeCompanyTarget::ConditionTrueCompany, + metric, + comparator, + value, + }) + } + } +} + fn real_grouped_effect_descriptor_metadata( descriptor_id: u32, ) -> Option { @@ -2446,6 +2725,7 @@ fn parse_synthetic_event_runtime_record_template( active: flags & 0x01 != 0, marks_collection_dirty: flags & 0x02 != 0, one_shot: flags & 0x04 != 0, + conditions: Vec::new(), effects, }) } @@ -2522,6 +2802,14 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool { } } +fn runtime_condition_supported_for_save_import(condition: &RuntimeCondition) -> bool { + match condition { + RuntimeCondition::CompanyNumericThreshold { .. } + | RuntimeCondition::TerritoryNumericThreshold { .. } + | RuntimeCondition::CompanyTerritoryNumericThreshold { .. } => true, + } +} + fn build_unsupported_event_runtime_record_summaries( live_entry_ids: &[u32], note: &str, @@ -2549,6 +2837,7 @@ fn build_unsupported_event_runtime_record_summaries( negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, notes: vec![note.to_string()], @@ -7830,6 +8119,11 @@ mod tests { subtype: 4, flag_bytes: vec![0x30; 25], candidate_name: Some("AutoPlant".to_string()), + comparator: None, + metric: None, + semantic_family: None, + semantic_preview: None, + requires_candidate_name_binding: false, notes: vec![], }]; let summary = derive_negative_sentinel_scope_summary( diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index db2334a..01a3eeb 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -3,8 +3,10 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use crate::{ - RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate, - RuntimeState, RuntimeSummary, calendar::BoundaryEventKind, + RuntimeCompanyControllerKind, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, + RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, RuntimeState, + RuntimeSummary, RuntimeTerritoryMetric, RuntimeTrackMetric, RuntimeTrackPieceCounts, + calendar::BoundaryEventKind, }; const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4]; @@ -79,6 +81,11 @@ struct AppliedEffectsSummary { removed_record_ids: Vec, } +#[derive(Debug, Default)] +struct ResolvedConditionContext { + matching_company_ids: BTreeSet, +} + pub fn execute_step_command( state: &mut RuntimeState, command: &StepCommand, @@ -212,19 +219,31 @@ fn service_trigger_kind( .or_insert(0) += 1; for index in eligible_indices { - let (record_id, record_effects, record_marks_collection_dirty, record_one_shot) = { + let ( + record_id, + record_conditions, + record_effects, + record_marks_collection_dirty, + record_one_shot, + ) = { let record = &state.event_runtime_records[index]; ( record.record_id, + record.conditions.clone(), record.effects.clone(), record.marks_collection_dirty, record.one_shot, ) }; + let Some(condition_context) = evaluate_record_conditions(state, &record_conditions)? else { + continue; + }; + let effect_summary = apply_runtime_effects( state, &record_effects, + &condition_context, &mut mutated_company_ids, &mut staged_event_graph_mutations, )?; @@ -275,6 +294,7 @@ fn service_trigger_kind( fn apply_runtime_effects( state: &mut RuntimeState, effects: &[RuntimeEffect], + condition_context: &ResolvedConditionContext, mutated_company_ids: &mut BTreeSet, staged_event_graph_mutations: &mut Vec, ) -> Result { @@ -286,7 +306,7 @@ fn apply_runtime_effects( state.world_flags.insert(key.clone(), *value); } RuntimeEffect::SetCompanyCash { target, value } => { - let company_ids = resolve_company_target_ids(state, target)?; + let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { let company = state .companies @@ -300,7 +320,7 @@ fn apply_runtime_effects( } } RuntimeEffect::DeactivateCompany { target } => { - let company_ids = resolve_company_target_ids(state, target)?; + let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { let company = state .companies @@ -319,7 +339,7 @@ fn apply_runtime_effects( } } RuntimeEffect::SetCompanyTrackLayingCapacity { target, value } => { - let company_ids = resolve_company_target_ids(state, target)?; + let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { let company = state .companies @@ -335,7 +355,7 @@ fn apply_runtime_effects( } } RuntimeEffect::AdjustCompanyCash { target, delta } => { - let company_ids = resolve_company_target_ids(state, target)?; + let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { let company = state .companies @@ -352,7 +372,7 @@ fn apply_runtime_effects( } } RuntimeEffect::AdjustCompanyDebt { target, delta } => { - let company_ids = resolve_company_target_ids(state, target)?; + let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { let company = state .companies @@ -456,9 +476,114 @@ fn commit_staged_event_graph_mutations( state.validate() } +fn evaluate_record_conditions( + state: &RuntimeState, + conditions: &[RuntimeCondition], +) -> Result, String> { + if conditions.is_empty() { + return Ok(Some(ResolvedConditionContext::default())); + } + + let mut company_matches: Option> = None; + + for condition in conditions { + match condition { + RuntimeCondition::CompanyNumericThreshold { + target, + metric, + comparator, + value, + } => { + let resolved = resolve_company_target_ids( + state, + target, + &ResolvedConditionContext::default(), + )?; + let matching = resolved + .into_iter() + .filter(|company_id| { + state.companies.iter().find(|company| company.company_id == *company_id).is_some_and( + |company| compare_condition_value( + company_metric_value(company, *metric), + *comparator, + *value, + ), + ) + }) + .collect::>(); + if matching.is_empty() { + return Ok(None); + } + intersect_company_matches(&mut company_matches, matching); + if company_matches.as_ref().is_some_and(BTreeSet::is_empty) { + return Ok(None); + } + } + RuntimeCondition::TerritoryNumericThreshold { + metric, + comparator, + value, + } => { + let actual = territory_metric_value(state, *metric); + if !compare_condition_value(actual, *comparator, *value) { + return Ok(None); + } + } + RuntimeCondition::CompanyTerritoryNumericThreshold { + target, + metric, + comparator, + value, + } => { + let resolved = resolve_company_target_ids( + state, + target, + &ResolvedConditionContext::default(), + )?; + let matching = resolved + .into_iter() + .filter(|company_id| { + compare_condition_value( + company_territory_metric_value(state, *company_id, *metric), + *comparator, + *value, + ) + }) + .collect::>(); + if matching.is_empty() { + return Ok(None); + } + intersect_company_matches(&mut company_matches, matching); + if company_matches.as_ref().is_some_and(BTreeSet::is_empty) { + return Ok(None); + } + } + } + } + + Ok(Some(ResolvedConditionContext { + matching_company_ids: company_matches.unwrap_or_default(), + })) +} + +fn intersect_company_matches( + company_matches: &mut Option>, + next: BTreeSet, +) { + match company_matches { + Some(existing) => { + existing.retain(|company_id| next.contains(company_id)); + } + None => { + *company_matches = Some(next); + } + } +} + fn resolve_company_target_ids( state: &RuntimeState, target: &RuntimeCompanyTarget, + condition_context: &ResolvedConditionContext, ) -> Result, String> { match target { RuntimeCompanyTarget::AllActive => Ok(state @@ -538,11 +663,101 @@ fn resolve_company_target_ids( } } RuntimeCompanyTarget::ConditionTrueCompany => { - Err("target requires condition-evaluation context".to_string()) + if condition_context.matching_company_ids.is_empty() { + Err("target requires condition-evaluation context".to_string()) + } else { + Ok(condition_context + .matching_company_ids + .iter() + .copied() + .collect()) + } } } } +fn company_metric_value(company: &crate::RuntimeCompany, metric: RuntimeCompanyMetric) -> i64 { + match metric { + RuntimeCompanyMetric::CurrentCash => company.current_cash, + RuntimeCompanyMetric::TotalDebt => company.debt as i64, + RuntimeCompanyMetric::CreditRating => company.credit_rating_score.unwrap_or(0), + RuntimeCompanyMetric::PrimeRate => company.prime_rate.unwrap_or(0), + RuntimeCompanyMetric::TrackPiecesTotal => i64::from(company.track_piece_counts.total), + RuntimeCompanyMetric::TrackPiecesSingle => i64::from(company.track_piece_counts.single), + RuntimeCompanyMetric::TrackPiecesDouble => i64::from(company.track_piece_counts.double), + RuntimeCompanyMetric::TrackPiecesTransition => { + i64::from(company.track_piece_counts.transition) + } + RuntimeCompanyMetric::TrackPiecesElectric => { + i64::from(company.track_piece_counts.electric) + } + RuntimeCompanyMetric::TrackPiecesNonElectric => { + i64::from(company.track_piece_counts.non_electric) + } + } +} + +fn territory_metric_value(state: &RuntimeState, metric: RuntimeTerritoryMetric) -> i64 { + state.territories + .iter() + .map(|territory| { + track_piece_metric_value( + territory.track_piece_counts, + territory_metric_to_track_metric(metric), + ) + }) + .sum() +} + +fn company_territory_metric_value( + state: &RuntimeState, + company_id: u32, + metric: RuntimeTrackMetric, +) -> i64 { + state.company_territory_track_piece_counts + .iter() + .filter(|entry| entry.company_id == company_id) + .map(|entry| track_piece_metric_value(entry.track_piece_counts, metric)) + .sum() +} + +fn track_piece_metric_value(counts: RuntimeTrackPieceCounts, metric: RuntimeTrackMetric) -> i64 { + match metric { + RuntimeTrackMetric::Total => i64::from(counts.total), + RuntimeTrackMetric::Single => i64::from(counts.single), + RuntimeTrackMetric::Double => i64::from(counts.double), + RuntimeTrackMetric::Transition => i64::from(counts.transition), + RuntimeTrackMetric::Electric => i64::from(counts.electric), + RuntimeTrackMetric::NonElectric => i64::from(counts.non_electric), + } +} + +fn territory_metric_to_track_metric(metric: RuntimeTerritoryMetric) -> RuntimeTrackMetric { + match metric { + RuntimeTerritoryMetric::TrackPiecesTotal => RuntimeTrackMetric::Total, + RuntimeTerritoryMetric::TrackPiecesSingle => RuntimeTrackMetric::Single, + RuntimeTerritoryMetric::TrackPiecesDouble => RuntimeTrackMetric::Double, + RuntimeTerritoryMetric::TrackPiecesTransition => RuntimeTrackMetric::Transition, + RuntimeTerritoryMetric::TrackPiecesElectric => RuntimeTrackMetric::Electric, + RuntimeTerritoryMetric::TrackPiecesNonElectric => RuntimeTrackMetric::NonElectric, + } +} + +fn compare_condition_value( + actual: i64, + comparator: RuntimeConditionComparator, + expected: i64, +) -> bool { + match comparator { + RuntimeConditionComparator::Ge => actual >= expected, + RuntimeConditionComparator::Le => actual <= expected, + RuntimeConditionComparator::Gt => actual > expected, + RuntimeConditionComparator::Lt => actual < expected, + RuntimeConditionComparator::Eq => actual == expected, + RuntimeConditionComparator::Ne => actual != expected, + } +} + fn apply_u64_delta(current: u64, delta: i64, company_id: u32) -> Result { if delta >= 0 { current @@ -583,10 +798,15 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), @@ -647,6 +867,7 @@ mod tests { marks_collection_dirty: true, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetWorldFlag { key: "runtime.effect_fired".to_string(), value: true, @@ -660,6 +881,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::AllActive, delta: 5, @@ -673,6 +895,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetSpecialCondition { label: "Dirty rerun fired".to_string(), value: 1, @@ -747,6 +970,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 5, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -755,6 +981,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 20, debt: 8, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -767,6 +996,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![ RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::Ids { ids: vec![2] }, @@ -803,6 +1033,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -811,6 +1044,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 20, debt: 2, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -825,6 +1061,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::HumanCompanies, delta: 5, @@ -838,6 +1075,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::AiCompanies, delta: 3, @@ -851,6 +1089,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::SelectedCompany, delta: 7, @@ -884,6 +1123,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::SelectedCompany, delta: 1, @@ -912,6 +1152,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::HumanCompanies, delta: 1, @@ -938,6 +1179,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 1, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -946,6 +1190,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 20, debt: 2, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: false, available_track_laying_capacity: None, }, @@ -954,6 +1201,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 30, debt: 3, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -967,6 +1217,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::AllActive, delta: 5, @@ -980,6 +1231,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::HumanCompanies, delta: 4, @@ -993,6 +1245,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::AiCompanies, delta: 6, @@ -1024,6 +1277,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: Some(8), }], @@ -1036,6 +1292,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::DeactivateCompany { target: RuntimeCompanyTarget::SelectedCompany, }], @@ -1063,6 +1320,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, current_cash: 10, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -1071,6 +1331,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Ai, current_cash: 20, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }, @@ -1083,6 +1346,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetCompanyTrackLayingCapacity { target: RuntimeCompanyTarget::Ids { ids: vec![2] }, value: Some(14), @@ -1113,6 +1377,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, delta: 1, @@ -1141,6 +1406,7 @@ mod tests { marks_collection_dirty: false, one_shot: true, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetWorldFlag { key: "one_shot".to_string(), value: true, @@ -1177,6 +1443,9 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Unknown, current_cash: 10, debt: 2, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, }], @@ -1188,6 +1457,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AdjustCompanyDebt { target: RuntimeCompanyTarget::AllActive, delta: -3, @@ -1215,6 +1485,7 @@ mod tests { marks_collection_dirty: false, one_shot: true, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 41, @@ -1222,6 +1493,7 @@ mod tests { active: true, marks_collection_dirty: false, one_shot: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetWorldFlag { key: "follow_on_later_pass".to_string(), value: true, @@ -1268,6 +1540,7 @@ mod tests { marks_collection_dirty: true, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 51, @@ -1275,6 +1548,7 @@ mod tests { active: true, marks_collection_dirty: false, one_shot: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetWorldFlag { key: "dirty_rerun_follow_on".to_string(), value: true, @@ -1314,6 +1588,7 @@ mod tests { marks_collection_dirty: false, one_shot: true, has_fired: false, + conditions: Vec::new(), effects: vec![ RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { @@ -1322,6 +1597,7 @@ mod tests { active: true, marks_collection_dirty: false, one_shot: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetCandidateAvailability { name: "Appended Industry".to_string(), value: 1, @@ -1341,6 +1617,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetWorldFlag { key: "deactivated_after_first_pass".to_string(), value: true, @@ -1354,6 +1631,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetSpecialCondition { label: "Activated On Second Pass".to_string(), value: 1, @@ -1367,6 +1645,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::SetWorldFlag { key: "removed_after_first_pass".to_string(), value: true, @@ -1436,6 +1715,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::AppendEventRecord { record: Box::new(RuntimeEventRecordTemplate { record_id: 71, @@ -1443,6 +1723,7 @@ mod tests { active: true, marks_collection_dirty: false, one_shot: false, + conditions: Vec::new(), effects: Vec::new(), }), }], @@ -1455,6 +1736,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: Vec::new(), }, ], @@ -1480,6 +1762,7 @@ mod tests { marks_collection_dirty: false, one_shot: false, has_fired: false, + conditions: Vec::new(), effects: vec![RuntimeEffect::ActivateEventRecord { record_id: 999 }], }], ..state() diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index f638fa1..c48f864 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -29,6 +29,8 @@ pub struct RuntimeSummary { pub metadata_count: usize, pub company_count: usize, pub active_company_count: usize, + pub territory_count: usize, + pub company_territory_track_count: usize, pub packed_event_collection_present: bool, pub packed_event_record_count: usize, pub packed_event_decoded_record_count: usize, @@ -42,6 +44,9 @@ pub struct RuntimeSummary { pub packed_event_blocked_company_condition_scope_disabled_count: usize, pub packed_event_blocked_player_condition_scope_count: usize, pub packed_event_blocked_territory_condition_scope_count: usize, + pub packed_event_blocked_missing_territory_context_count: usize, + pub packed_event_blocked_named_territory_binding_count: usize, + pub packed_event_blocked_unmapped_ordinary_condition_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, @@ -131,6 +136,8 @@ impl RuntimeSummary { .iter() .filter(|company| company.active) .count(), + territory_count: state.territories.len(), + company_territory_track_count: state.company_territory_track_piece_counts.len(), packed_event_collection_present: state.packed_event_collection.is_some(), packed_event_record_count: state .packed_event_collection @@ -267,6 +274,48 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_missing_territory_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_territory_context") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_named_territory_binding_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_named_territory_binding") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_unmapped_ordinary_condition_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_unmapped_ordinary_condition") + }) + .count() + }) + .unwrap_or(0), packed_event_blocked_missing_compact_control_count: state .packed_event_collection .as_ref() @@ -355,6 +404,7 @@ mod tests { use crate::{ CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary, + RuntimeTrackPieceCounts, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, }; @@ -375,6 +425,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: Some(RuntimePackedEventCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), mechanism_family: "classic-save-rehydrate-v1".to_string(), @@ -407,6 +459,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), grouped_company_targets: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: Some("blocked_missing_compact_control".to_string()), @@ -431,6 +484,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), grouped_company_targets: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: Some("blocked_missing_company_context".to_string()), @@ -455,6 +509,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), grouped_company_targets: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: Some( @@ -481,6 +536,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), grouped_company_targets: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: Some("blocked_player_condition_scope".to_string()), @@ -505,6 +561,7 @@ mod tests { grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: Vec::new(), grouped_company_targets: Vec::new(), + decoded_conditions: Vec::new(), decoded_actions: Vec::new(), executable_import_ready: false, import_outcome: Some("blocked_territory_condition_scope".to_string()), @@ -573,6 +630,9 @@ mod tests { company_id: 1, current_cash: 10, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, controller_kind: RuntimeCompanyControllerKind::Human, @@ -581,12 +641,17 @@ mod tests { company_id: 2, current_cash: 20, debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), active: false, available_track_laying_capacity: Some(7), controller_kind: RuntimeCompanyControllerKind::Ai, }, ], selected_company_id: None, + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), diff --git a/docs/README.md b/docs/README.md index 90eaac6..f9b1962 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,8 +84,11 @@ The highest-value next passes are now: - widen real packed-event executable coverage descriptor by descriptor after identity, target mask, and normalized effect semantics are all grounded, not just after row framing is parsed - the first grounded condition-side unlock now exists for negative-sentinel `raw_condition_id = -1` - company scopes; broader ordinary condition-id evaluation and player/territory runtime ownership - are the remaining condition frontier, and mixed supported/unsupported real rows stay parity-only + company scopes, and the first ordinary nonnegative condition batch now executes too: numeric + thresholds for company finance, company track, aggregate territory track, and company-territory + track +- named-territory ordinary rows and player-owned condition scope are still the remaining condition + frontier, and mixed supported/unsupported real rows stay parity-only - 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 9639ab3..11f2f90 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -35,11 +35,15 @@ Implemented today: `raw_condition_id = -1` company scope lowers `condition_true_company` into normalized company targets during import, while player and territory scope variants remain parity-visible and explicitly blocked +- the first ordinary nonnegative condition-id batch now executes too: numeric-threshold company + finance, company track, aggregate territory track, and company-territory track rows can import + through overlay-backed runtime context, while named-territory bindings stay parity-only and + player-owned condition scope still has no runtime owner That means the next implementation work is breadth, not bootstrap. The recommended next slice is -ordinary nonnegative condition-id semantics plus runtime ownership for the still-blocked player and -territory scope families, alongside broader real grouped-descriptor coverage beyond the current -company-scoped batch. +broader ordinary condition-id coverage beyond numeric thresholds, plus runtime ownership for the +still-blocked player-scoped and named-territory condition families, alongside wider real +grouped-descriptor coverage beyond the current company-scoped batch. ## Why This Boundary @@ -236,8 +240,10 @@ Current status: raw `.smp` binaries - 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 ordinary condition-id - evaluation and player/territory runtime ownership, not first-pass captured-runtime plumbing +- aggregate territory context and company-territory track counters now flow through tracked overlay + snapshots, so the remaining gap is broader ordinary condition-id coverage beyond numeric + thresholds, named-territory binding, player runtime ownership, and wider real grouped-descriptor + semantic coverage, not first-pass captured-runtime plumbing ### Milestone 4: Domain Expansion @@ -380,48 +386,52 @@ Checked-in fixture families already include: ## Next Slice -The recommended next implementation slice is broader real grouped-descriptor coverage on top of the -now-stable compact-control, symbolic-target, and current company-scoped real-family batch. +The recommended next implementation slice is broader ordinary-condition breadth on top of the +now-stable numeric-threshold, overlay-context, and current company-scoped real-descriptor batch. Target behavior: -- keep descriptors `2` `Company Cash`, `13` `Deactivate Company`, and `16` - `Company Track Pieces Buildable` as the proof that real grouped rows can cross the whole path: - parse, semantic summary, overlay-backed import, and ordinary trigger execution -- recover more real descriptor identities from the checked-in effect table and expose their target - masks and conservative semantic previews without guessing unsupported behavior -- widen executable real import only when both descriptor identity and runtime effect semantics are - grounded enough to map into the normalized runtime path honestly -- keep condition-relative company scopes explicit until a real condition evaluator exists, instead - of silently degrading or inventing target resolution +- preserve the current proof set for real ordinary-condition execution: + company finance, company track, aggregate territory track, and company-territory track numeric + thresholds all pass through parse, semantic summary, overlay-backed import, and ordinary trigger + execution +- extend ordinary condition coverage beyond numeric thresholds only when comparator semantics, + runtime ownership, and binding rules are grounded enough to lower honestly into the normalized + runtime path +- keep named-territory ordinary rows explicit and parity-visible until candidate-name territory + binding is grounded +- keep player-owned condition scope explicit and parity-visible until there is a first-class player + runtime model +- continue widening real grouped-descriptor execution only when both descriptor identity and + runtime effect semantics are grounded enough to map into the normalized runtime path honestly Public-model expectations for that slice: -- additional checked-in grouped-descriptor metadata entries keyed by recovered descriptor id -- more parity summaries with real descriptor labels, target masks, parameter families, and semantic - previews -- more selective real-row `decoded_actions` only where the descriptor-to-runtime mapping is - supported end to end +- additional checked-in ordinary-condition metadata entries beyond the current numeric-threshold + allowlist +- richer runtime ownership for still-blocked condition domains such as named territory and player + scope +- more selective real-row `decoded_conditions` and `decoded_actions` only where the + condition/effect-to-runtime mapping is supported end to end Fixture work for that slice: -- preserve the parity-heavy tracked sample as the condition-relative blocked frontier while it now - carries recovered `Company Cash` semantics with executable import readiness -- keep overlay-backed captured fixtures for the executable company-scoped real families: - `Company Cash`, `Deactivate Company`, and `Company Track Pieces Buildable` -- keep a mixed real-row overlay fixture to lock the all-or-nothing parity rule for partially - supported real records +- preserve the new ordinary-condition tracked overlays for executable company finance, company + track, aggregate territory track, and company-territory track thresholds +- preserve the named-territory tracked overlay as the explicit binding blocker frontier +- keep the older negative-sentinel, mixed real-row, and company-scoped descriptor fixtures green so + ordinary-condition breadth does not regress descriptor-side execution - keep synthetic harness, save-slice, and overlay paths green as the real descriptor surface widens Current local constraint: - the local checked-in and on-disk `.gms` corpus still does not provide a richer captured packed - event save set, so descriptor recovery must continue to rely on the grounded static tables and - tracked JSON artifacts until new captures exist + event save set, so wider ordinary-condition and descriptor recovery still needs to rely on the + grounded static tables and tracked JSON artifacts until new captures exist Do not mix this slice with: - shell queue/modal behavior - territory-access or selected-profile parity -- broad condition evaluation without grounded runtime ownership +- speculative condition execution without grounded runtime ownership - speculative executable import for real rows whose descriptor meaning is still weak diff --git a/fixtures/runtime/packed-event-ordinary-company-finance-overlay-fixture.json b/fixtures/runtime/packed-event-ordinary-company-finance-overlay-fixture.json new file mode 100644 index 0000000..26d54ca --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-finance-overlay-fixture.json @@ -0,0 +1,93 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-ordinary-company-finance-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving real ordinary Current Cash conditions gate Company Cash through the normal runtime path." + }, + "state_import_path": "packed-event-ordinary-company-finance-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, + "active_company_count": 3, + "territory_count": 1, + "company_territory_track_count": 3, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 1, + "packed_event_parity_only_record_count": 1, + "packed_event_blocked_unmapped_ordinary_condition_count": 0, + "event_runtime_record_count": 1, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1, + "total_company_cash": 623 + }, + "expected_state_fragment": { + "companies": [ + { + "company_id": 1, + "current_cash": 333 + }, + { + "company_id": 2, + "current_cash": 90 + }, + { + "company_id": 3, + "current_cash": 200 + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_conditions": [ + { + "kind": "company_numeric_threshold", + "target": { + "kind": "selected_company" + }, + "metric": "current_cash", + "comparator": "ge", + "value": 100 + } + ] + } + ] + }, + "event_runtime_records": [ + { + "record_id": 41, + "service_count": 1, + "conditions": [ + { + "kind": "company_numeric_threshold", + "target": { + "kind": "selected_company" + }, + "metric": "current_cash", + "comparator": "ge", + "value": 100 + } + ], + "effects": [ + { + "kind": "set_company_cash", + "target": { + "kind": "condition_true_company" + }, + "value": 333 + } + ] + } + ] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-company-finance-overlay.json b/fixtures/runtime/packed-event-ordinary-company-finance-overlay.json new file mode 100644 index 0000000..e1cd3da --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-finance-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-ordinary-company-finance-overlay", + "source": { + "description": "Overlay import combining captured company context with the real ordinary company-finance threshold sample." + }, + "base_snapshot_path": "packed-event-ordinary-condition-overlay-base-snapshot.json", + "save_slice_path": "packed-event-ordinary-company-finance-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-ordinary-company-finance-save-slice.json b/fixtures/runtime/packed-event-ordinary-company-finance-save-slice.json new file mode 100644 index 0000000..ca3498a --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-finance-save-slice.json @@ -0,0 +1,139 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-ordinary-company-finance-save-slice", + "source": { + "description": "Tracked save-slice document with a real ordinary company-finance threshold row gating Company Cash.", + "original_save_filename": "captured-ordinary-company-finance.gms", + "original_save_sha256": "ordinary-company-finance-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves ordinary Current Cash threshold import through the real packed-event path" + ] + }, + "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": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 41, + "live_record_count": 1, + "live_entry_ids": [41], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 41, + "payload_offset": 29200, + "payload_len": 176, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 7, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 7, + "primary_selector_0x7f0": 42, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 2, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [0, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 1, + "standalone_condition_rows": [ + { + "row_index": 0, + "raw_condition_id": 1802, + "subtype": 0, + "flag_bytes": [100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "candidate_name": null, + "comparator": "ge", + "metric": "Current Cash", + "semantic_family": "numeric_threshold", + "semantic_preview": "Test Current Cash >= 100", + "requires_candidate_name_binding": false, + "notes": [] + } + ], + "negative_sentinel_scope": { + "company_test_scope": "selected_company_only", + "player_test_scope": "disabled", + "territory_scope_selector_is_0x63": false, + "source_row_indexes": [0] + }, + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 2, + "descriptor_label": "Company Cash", + "target_mask_bits": 1, + "parameter_family": "company_finance_scalar", + "opcode": 8, + "raw_scalar_value": 333, + "value_byte_0x09": 1, + "value_dword_0x0d": 12, + "value_byte_0x11": 2, + "value_byte_0x12": 3, + "value_word_0x14": 24, + "value_word_0x16": 36, + "row_shape": "multivalue_scalar", + "semantic_family": "multivalue_scalar", + "semantic_preview": "Set Company Cash to 333 with aux [2, 3, 24, 36]", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [ + { + "kind": "company_numeric_threshold", + "target": { + "kind": "condition_true_company" + }, + "metric": "current_cash", + "comparator": "ge", + "value": 100 + } + ], + "decoded_actions": [ + { + "kind": "set_company_cash", + "target": { + "kind": "condition_true_company" + }, + "value": 333 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "ordinary Current Cash threshold lowers condition-relative company scope at import time" + ] + } + ] + }, + "notes": [ + "real ordinary company-finance threshold sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-company-territory-track-overlay-fixture.json b/fixtures/runtime/packed-event-ordinary-company-territory-track-overlay-fixture.json new file mode 100644 index 0000000..1db2b9e --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-territory-track-overlay-fixture.json @@ -0,0 +1,67 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-ordinary-company-territory-track-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving real ordinary company-territory thresholds gate Company Cash through the normal runtime path." + }, + "state_import_path": "packed-event-ordinary-company-territory-track-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, + "active_company_count": 3, + "territory_count": 1, + "company_territory_track_count": 3, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 1, + "packed_event_parity_only_record_count": 1, + "packed_event_blocked_missing_territory_context_count": 0, + "event_runtime_record_count": 1, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1, + "total_company_cash": 845 + }, + "expected_state_fragment": { + "companies": [ + { + "company_id": 1, + "current_cash": 555 + }, + { + "company_id": 2, + "current_cash": 90 + }, + { + "company_id": 3, + "current_cash": 200 + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_conditions": [ + { + "kind": "company_territory_numeric_threshold", + "target": { + "kind": "selected_company" + }, + "metric": "total", + "comparator": "ge", + "value": 10 + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-ordinary-company-territory-track-overlay.json b/fixtures/runtime/packed-event-ordinary-company-territory-track-overlay.json new file mode 100644 index 0000000..cd9f29d --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-territory-track-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-ordinary-company-territory-track-overlay", + "source": { + "description": "Overlay import combining company and territory context with the real ordinary company-territory threshold sample." + }, + "base_snapshot_path": "packed-event-ordinary-condition-overlay-base-snapshot.json", + "save_slice_path": "packed-event-ordinary-company-territory-track-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-ordinary-company-territory-track-save-slice.json b/fixtures/runtime/packed-event-ordinary-company-territory-track-save-slice.json new file mode 100644 index 0000000..ee09f5f --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-territory-track-save-slice.json @@ -0,0 +1,139 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-ordinary-company-territory-track-save-slice", + "source": { + "description": "Tracked save-slice document with a real ordinary company-territory threshold row gating Company Cash.", + "original_save_filename": "captured-ordinary-company-territory-track.gms", + "original_save_sha256": "ordinary-company-territory-track-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves company-territory thresholds import when both company and territory overlay context exist" + ] + }, + "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": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 44, + "live_record_count": 1, + "live_entry_ids": [44], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 44, + "payload_offset": 29296, + "payload_len": 176, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 7, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 7, + "primary_selector_0x7f0": 99, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 2, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [0, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 1, + "standalone_condition_rows": [ + { + "row_index": 0, + "raw_condition_id": 2323, + "subtype": 0, + "flag_bytes": [10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "candidate_name": null, + "comparator": "ge", + "metric": "Company-Territory Track Pieces", + "semantic_family": "numeric_threshold", + "semantic_preview": "Test Company-Territory Track Pieces >= 10", + "requires_candidate_name_binding": false, + "notes": [] + } + ], + "negative_sentinel_scope": { + "company_test_scope": "selected_company_only", + "player_test_scope": "disabled", + "territory_scope_selector_is_0x63": true, + "source_row_indexes": [0] + }, + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 2, + "descriptor_label": "Company Cash", + "target_mask_bits": 1, + "parameter_family": "company_finance_scalar", + "opcode": 8, + "raw_scalar_value": 555, + "value_byte_0x09": 1, + "value_dword_0x0d": 12, + "value_byte_0x11": 2, + "value_byte_0x12": 3, + "value_word_0x14": 24, + "value_word_0x16": 36, + "row_shape": "multivalue_scalar", + "semantic_family": "multivalue_scalar", + "semantic_preview": "Set Company Cash to 555 with aux [2, 3, 24, 36]", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [ + { + "kind": "company_territory_numeric_threshold", + "target": { + "kind": "condition_true_company" + }, + "metric": "total", + "comparator": "ge", + "value": 10 + } + ], + "decoded_actions": [ + { + "kind": "set_company_cash", + "target": { + "kind": "condition_true_company" + }, + "value": 555 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "company-territory thresholds lower condition-relative company scope when overlay territory context is available" + ] + } + ] + }, + "notes": [ + "real ordinary company-territory threshold sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-company-track-overlay-fixture.json b/fixtures/runtime/packed-event-ordinary-company-track-overlay-fixture.json new file mode 100644 index 0000000..4dd244d --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-track-overlay-fixture.json @@ -0,0 +1,54 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-ordinary-company-track-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving real ordinary company-track thresholds gate Company Track Pieces Buildable through the normal runtime path." + }, + "state_import_path": "packed-event-ordinary-company-track-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, + "active_company_count": 3, + "territory_count": 1, + "company_territory_track_count": 3, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 1, + "packed_event_parity_only_record_count": 1, + "event_runtime_record_count": 1, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1, + "total_company_cash": 440 + }, + "expected_state_fragment": { + "companies": [ + { + "company_id": 1, + "available_track_laying_capacity": 12 + }, + { + "company_id": 2, + "available_track_laying_capacity": null + }, + { + "company_id": 3, + "available_track_laying_capacity": null + } + ], + "event_runtime_records": [ + { + "record_id": 42, + "service_count": 1 + } + ] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-company-track-overlay.json b/fixtures/runtime/packed-event-ordinary-company-track-overlay.json new file mode 100644 index 0000000..34e5159 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-track-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-ordinary-company-track-overlay", + "source": { + "description": "Overlay import combining captured company context with the real ordinary company-track threshold sample." + }, + "base_snapshot_path": "packed-event-ordinary-condition-overlay-base-snapshot.json", + "save_slice_path": "packed-event-ordinary-company-track-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-ordinary-company-track-save-slice.json b/fixtures/runtime/packed-event-ordinary-company-track-save-slice.json new file mode 100644 index 0000000..d730af8 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-company-track-save-slice.json @@ -0,0 +1,139 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-ordinary-company-track-save-slice", + "source": { + "description": "Tracked save-slice document with a real ordinary company-track threshold row gating Company Track Pieces Buildable.", + "original_save_filename": "captured-ordinary-company-track.gms", + "original_save_sha256": "ordinary-company-track-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves ordinary Company Track Pieces threshold import through the real packed-event path" + ] + }, + "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": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 42, + "live_record_count": 1, + "live_entry_ids": [42], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 42, + "payload_offset": 29232, + "payload_len": 176, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 7, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 7, + "primary_selector_0x7f0": 42, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 2, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [0, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 1, + "standalone_condition_rows": [ + { + "row_index": 0, + "raw_condition_id": 2293, + "subtype": 0, + "flag_bytes": [20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "candidate_name": null, + "comparator": "ge", + "metric": "Company Track Pieces", + "semantic_family": "numeric_threshold", + "semantic_preview": "Test Company Track Pieces >= 20", + "requires_candidate_name_binding": false, + "notes": [] + } + ], + "negative_sentinel_scope": { + "company_test_scope": "selected_company_only", + "player_test_scope": "disabled", + "territory_scope_selector_is_0x63": false, + "source_row_indexes": [0] + }, + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 16, + "descriptor_label": "Company Track Pieces Buildable", + "target_mask_bits": 1, + "parameter_family": "company_build_limit_scalar", + "opcode": 3, + "raw_scalar_value": 12, + "value_byte_0x09": 0, + "value_dword_0x0d": 0, + "value_byte_0x11": 0, + "value_byte_0x12": 0, + "value_word_0x14": 0, + "value_word_0x16": 0, + "row_shape": "scalar_assignment", + "semantic_family": "scalar_assignment", + "semantic_preview": "Set Company Track Pieces Buildable to 12", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [ + { + "kind": "company_numeric_threshold", + "target": { + "kind": "condition_true_company" + }, + "metric": "track_pieces_total", + "comparator": "ge", + "value": 20 + } + ], + "decoded_actions": [ + { + "kind": "set_company_track_laying_capacity", + "target": { + "kind": "condition_true_company" + }, + "value": 12 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "ordinary Company Track Pieces threshold lowers condition-relative company scope at import time" + ] + } + ] + }, + "notes": [ + "real ordinary company-track threshold sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-condition-overlay-base-snapshot.json b/fixtures/runtime/packed-event-ordinary-condition-overlay-base-snapshot.json new file mode 100644 index 0000000..655db3e --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-condition-overlay-base-snapshot.json @@ -0,0 +1,132 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-ordinary-condition-overlay-base-snapshot", + "source": { + "description": "Base runtime snapshot supplying company, selection, and aggregate territory context for ordinary-condition packed-event overlays." + }, + "state": { + "calendar": { + "year": 1840, + "month_slot": 1, + "phase_slot": 2, + "tick_slot": 3 + }, + "world_flags": { + "base.only": true + }, + "metadata": { + "base.note": "preserve ordinary-condition overlay context" + }, + "companies": [ + { + "company_id": 1, + "current_cash": 150, + "debt": 80, + "credit_rating_score": 650, + "prime_rate": 5, + "controller_kind": "human", + "track_piece_counts": { + "total": 20, + "single": 5, + "double": 8, + "transition": 1, + "electric": 3, + "non_electric": 17 + } + }, + { + "company_id": 2, + "current_cash": 90, + "debt": 40, + "credit_rating_score": 480, + "prime_rate": 6, + "controller_kind": "ai", + "track_piece_counts": { + "total": 8, + "single": 2, + "double": 2, + "transition": 0, + "electric": 1, + "non_electric": 7 + } + }, + { + "company_id": 3, + "current_cash": 200, + "debt": 10, + "credit_rating_score": 720, + "prime_rate": 4, + "controller_kind": "human", + "track_piece_counts": { + "total": 30, + "single": 10, + "double": 12, + "transition": 2, + "electric": 8, + "non_electric": 22 + } + } + ], + "selected_company_id": 1, + "territories": [ + { + "territory_id": 7, + "track_piece_counts": { + "total": 50, + "single": 10, + "double": 20, + "transition": 5, + "electric": 15, + "non_electric": 35 + } + } + ], + "company_territory_track_piece_counts": [ + { + "company_id": 1, + "territory_id": 7, + "track_piece_counts": { + "total": 12, + "single": 3, + "double": 5, + "transition": 1, + "electric": 4, + "non_electric": 8 + } + }, + { + "company_id": 2, + "territory_id": 7, + "track_piece_counts": { + "total": 7, + "single": 2, + "double": 2, + "transition": 0, + "electric": 1, + "non_electric": 6 + } + }, + { + "company_id": 3, + "territory_id": 7, + "track_piece_counts": { + "total": 15, + "single": 5, + "double": 6, + "transition": 2, + "electric": 5, + "non_electric": 10 + } + } + ], + "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-ordinary-named-territory-overlay-fixture.json b/fixtures/runtime/packed-event-ordinary-named-territory-overlay-fixture.json new file mode 100644 index 0000000..8a7d4f4 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-named-territory-overlay-fixture.json @@ -0,0 +1,63 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-ordinary-named-territory-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving named-territory ordinary conditions stay parity-only with an explicit blocker." + }, + "state_import_path": "packed-event-ordinary-named-territory-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, + "active_company_count": 3, + "territory_count": 1, + "company_territory_track_count": 3, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 0, + "packed_event_parity_only_record_count": 1, + "packed_event_blocked_named_territory_binding_count": 1, + "event_runtime_record_count": 0, + "total_event_record_service_count": 0, + "total_trigger_dispatch_count": 1, + "total_company_cash": 440 + }, + "expected_state_fragment": { + "companies": [ + { + "company_id": 1, + "current_cash": 150 + }, + { + "company_id": 2, + "current_cash": 90 + }, + { + "company_id": 3, + "current_cash": 200 + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "blocked_named_territory_binding", + "standalone_condition_rows": [ + { + "candidate_name": "Appalachia", + "requires_candidate_name_binding": true + } + ] + } + ] + }, + "event_runtime_records": [] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-named-territory-overlay.json b/fixtures/runtime/packed-event-ordinary-named-territory-overlay.json new file mode 100644 index 0000000..9a9675b --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-named-territory-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-ordinary-named-territory-overlay", + "source": { + "description": "Overlay import combining aggregate territory context with the real named-territory threshold sample." + }, + "base_snapshot_path": "packed-event-ordinary-condition-overlay-base-snapshot.json", + "save_slice_path": "packed-event-ordinary-named-territory-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-ordinary-named-territory-save-slice.json b/fixtures/runtime/packed-event-ordinary-named-territory-save-slice.json new file mode 100644 index 0000000..e6e873e --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-named-territory-save-slice.json @@ -0,0 +1,132 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-ordinary-named-territory-save-slice", + "source": { + "description": "Tracked save-slice document with a real ordinary named-territory threshold row that stays parity-only.", + "original_save_filename": "captured-ordinary-named-territory.gms", + "original_save_sha256": "ordinary-named-territory-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "locks the named-territory binding blocker for ordinary condition rows" + ] + }, + "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": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 45, + "live_record_count": 1, + "live_entry_ids": [45], + "decoded_record_count": 1, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 45, + "payload_offset": 29328, + "payload_len": 186, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 7, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 7, + "primary_selector_0x7f0": 99, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 0, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 1, + "standalone_condition_rows": [ + { + "row_index": 0, + "raw_condition_id": 2313, + "subtype": 0, + "flag_bytes": [10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "candidate_name": "Appalachia", + "comparator": "ge", + "metric": "Territory Track Pieces", + "semantic_family": "numeric_threshold", + "semantic_preview": "Test Territory Track Pieces >= 10", + "requires_candidate_name_binding": true, + "notes": [ + "condition row carries candidate-name side string" + ] + } + ], + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 2, + "descriptor_label": "Company Cash", + "target_mask_bits": 1, + "parameter_family": "company_finance_scalar", + "opcode": 8, + "raw_scalar_value": 777, + "value_byte_0x09": 1, + "value_dword_0x0d": 12, + "value_byte_0x11": 2, + "value_byte_0x12": 3, + "value_word_0x14": 24, + "value_word_0x16": 36, + "row_shape": "multivalue_scalar", + "semantic_family": "multivalue_scalar", + "semantic_preview": "Set Company Cash to 777 with aux [2, 3, 24, 36]", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [ + { + "kind": "territory_numeric_threshold", + "metric": "track_pieces_total", + "comparator": "ge", + "value": 10 + } + ], + "decoded_actions": [ + { + "kind": "set_company_cash", + "target": { + "kind": "selected_company" + }, + "value": 777 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "candidate-name territory binding remains parity-only in this slice" + ] + } + ] + }, + "notes": [ + "real ordinary named-territory threshold parity sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-territory-track-overlay-fixture.json b/fixtures/runtime/packed-event-ordinary-territory-track-overlay-fixture.json new file mode 100644 index 0000000..7f8e712 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-territory-track-overlay-fixture.json @@ -0,0 +1,64 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-ordinary-territory-track-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving aggregate territory thresholds can gate real packed-event execution when overlay territory context is present." + }, + "state_import_path": "packed-event-ordinary-territory-track-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, + "active_company_count": 3, + "territory_count": 1, + "company_territory_track_count": 3, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 1, + "packed_event_parity_only_record_count": 1, + "packed_event_blocked_missing_territory_context_count": 0, + "event_runtime_record_count": 1, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1, + "total_company_cash": 734 + }, + "expected_state_fragment": { + "companies": [ + { + "company_id": 1, + "current_cash": 444 + }, + { + "company_id": 2, + "current_cash": 90 + }, + { + "company_id": 3, + "current_cash": 200 + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_conditions": [ + { + "kind": "territory_numeric_threshold", + "metric": "track_pieces_total", + "comparator": "ge", + "value": 40 + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-ordinary-territory-track-overlay.json b/fixtures/runtime/packed-event-ordinary-territory-track-overlay.json new file mode 100644 index 0000000..4564ae3 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-territory-track-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-ordinary-territory-track-overlay", + "source": { + "description": "Overlay import combining aggregate territory context with the real ordinary territory-track threshold sample." + }, + "base_snapshot_path": "packed-event-ordinary-condition-overlay-base-snapshot.json", + "save_slice_path": "packed-event-ordinary-territory-track-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-ordinary-territory-track-save-slice.json b/fixtures/runtime/packed-event-ordinary-territory-track-save-slice.json new file mode 100644 index 0000000..037d604 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-territory-track-save-slice.json @@ -0,0 +1,130 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-ordinary-territory-track-save-slice", + "source": { + "description": "Tracked save-slice document with a real ordinary territory-track threshold row gating Company Cash.", + "original_save_filename": "captured-ordinary-territory-track.gms", + "original_save_sha256": "ordinary-territory-track-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves aggregate territory thresholds import when overlay territory context exists" + ] + }, + "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": 29696, + "packed_state_version": 1001, + "packed_state_version_hex": "0x000003e9", + "live_id_bound": 43, + "live_record_count": 1, + "live_entry_ids": [43], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 43, + "payload_offset": 29264, + "payload_len": 176, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 7, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 7, + "primary_selector_0x7f0": 99, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 0, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 1, + "standalone_condition_rows": [ + { + "row_index": 0, + "raw_condition_id": 2313, + "subtype": 0, + "flag_bytes": [40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "candidate_name": null, + "comparator": "ge", + "metric": "Territory Track Pieces", + "semantic_family": "numeric_threshold", + "semantic_preview": "Test Territory Track Pieces >= 40", + "requires_candidate_name_binding": false, + "notes": [] + } + ], + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 2, + "descriptor_label": "Company Cash", + "target_mask_bits": 1, + "parameter_family": "company_finance_scalar", + "opcode": 8, + "raw_scalar_value": 444, + "value_byte_0x09": 1, + "value_dword_0x0d": 12, + "value_byte_0x11": 2, + "value_byte_0x12": 3, + "value_word_0x14": 24, + "value_word_0x16": 36, + "row_shape": "multivalue_scalar", + "semantic_family": "multivalue_scalar", + "semantic_preview": "Set Company Cash to 444 with aux [2, 3, 24, 36]", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [ + { + "kind": "territory_numeric_threshold", + "metric": "track_pieces_total", + "comparator": "ge", + "value": 40 + } + ], + "decoded_actions": [ + { + "kind": "set_company_cash", + "target": { + "kind": "selected_company" + }, + "value": 444 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "aggregate territory thresholds execute only when overlay territory context is available" + ] + } + ] + }, + "notes": [ + "real ordinary aggregate territory-track threshold sample" + ] + } +}