From ca208f74e0620d7e2f8e2223a4332fe9b647a14b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 15 Apr 2026 19:15:47 -0700 Subject: [PATCH] Add territory and player packed event import --- README.md | 18 +- crates/rrt-fixtures/src/load.rs | 4 + crates/rrt-fixtures/src/schema.rs | 60 ++ crates/rrt-runtime/src/import.rs | 730 +++++++++++++----- crates/rrt-runtime/src/lib.rs | 7 +- crates/rrt-runtime/src/persistence.rs | 2 + crates/rrt-runtime/src/runtime.rs | 198 ++++- crates/rrt-runtime/src/smp.rs | 58 +- crates/rrt-runtime/src/step.rs | 164 +++- crates/rrt-runtime/src/summary.rs | 81 ++ docs/README.md | 8 +- docs/runtime-rehost-plan.md | 40 +- ...med-company-territory-overlay-fixture.json | 71 ++ ...inary-named-company-territory-overlay.json | 9 + ...ry-named-company-territory-save-slice.json | 144 ++++ ...-territory-executable-overlay-fixture.json | 75 ++ ...ry-named-territory-executable-overlay.json | 9 + ...t-ordinary-named-territory-save-slice.json | 3 + ...acked-event-parity-save-slice-fixture.json | 10 +- .../packed-event-parity-save-slice.json | 2 +- ...ked-event-player-cash-overlay-fixture.json | 57 ++ .../packed-event-player-cash-overlay.json | 9 + .../packed-event-player-cash-save-slice.json | 126 +++ ...erritory-player-overlay-base-snapshot.json | 158 ++++ ...t-territory-policy-save-slice-fixture.json | 39 + ...ked-event-territory-policy-save-slice.json | 102 +++ 26 files changed, 1912 insertions(+), 272 deletions(-) create mode 100644 fixtures/runtime/packed-event-ordinary-named-company-territory-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-ordinary-named-company-territory-overlay.json create mode 100644 fixtures/runtime/packed-event-ordinary-named-company-territory-save-slice.json create mode 100644 fixtures/runtime/packed-event-ordinary-named-territory-executable-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-ordinary-named-territory-executable-overlay.json create mode 100644 fixtures/runtime/packed-event-player-cash-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-player-cash-overlay.json create mode 100644 fixtures/runtime/packed-event-player-cash-save-slice.json create mode 100644 fixtures/runtime/packed-event-territory-player-overlay-base-snapshot.json create mode 100644 fixtures/runtime/packed-event-territory-policy-save-slice-fixture.json create mode 100644 fixtures/runtime/packed-event-territory-policy-save-slice.json diff --git a/README.md b/README.md index 320d11f..9faf4bc 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,16 @@ frontier is broader real grouped-descriptor coverage on top of the existing save overlay-import, compact-control, and symbolic company-target workflows. The runtime already carries selected-company and controller-role context through overlay imports, and real descriptors `2` `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, 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. +execute through the ordinary runtime path, and descriptor `1` `Player Cash` now joins that batch +through the same service engine. Synthetic packed records still exercise the same runtime without a +parallel packed executor. The first grounded condition-side unlock now exists for 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. Exact +named-territory binding now executes, while descriptor `3` `Territory - Allow All` remains the +explicit parity-only descriptor frontier. 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 ba8c095..01903d2 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -174,6 +174,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -339,6 +341,8 @@ mod tests { available_track_laying_capacity: None, }], selected_company_id: Some(42), + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, diff --git a/crates/rrt-fixtures/src/schema.rs b/crates/rrt-fixtures/src/schema.rs index 562a837..aa07ffa 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -64,6 +64,8 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub active_company_count: Option, #[serde(default)] + pub player_count: Option, + #[serde(default)] pub territory_count: Option, #[serde(default)] pub company_territory_track_count: Option, @@ -86,8 +88,16 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_blocked_missing_company_role_context_count: Option, #[serde(default)] + pub packed_event_blocked_missing_player_context_count: Option, + #[serde(default)] + pub packed_event_blocked_missing_player_selection_context_count: Option, + #[serde(default)] + pub packed_event_blocked_missing_player_role_context_count: Option, + #[serde(default)] pub packed_event_blocked_missing_condition_context_count: Option, #[serde(default)] + pub packed_event_blocked_missing_player_condition_context_count: Option, + #[serde(default)] pub packed_event_blocked_company_condition_scope_disabled_count: Option, #[serde(default)] pub packed_event_blocked_player_condition_scope_count: Option, @@ -104,6 +114,8 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_blocked_unmapped_real_descriptor_count: Option, #[serde(default)] + pub packed_event_blocked_territory_policy_descriptor_count: Option, + #[serde(default)] pub packed_event_blocked_structural_only_count: Option, #[serde(default)] pub event_runtime_record_count: Option, @@ -353,6 +365,14 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.player_count { + if actual.player_count != count { + mismatches.push(format!( + "player_count mismatch: expected {count}, got {}", + actual.player_count + )); + } + } if let Some(count) = self.territory_count { if actual.territory_count != count { mismatches.push(format!( @@ -441,6 +461,30 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_missing_player_context_count { + if actual.packed_event_blocked_missing_player_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_player_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_player_context_count + )); + } + } + if let Some(count) = self.packed_event_blocked_missing_player_selection_context_count { + if actual.packed_event_blocked_missing_player_selection_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_player_selection_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_player_selection_context_count + )); + } + } + if let Some(count) = self.packed_event_blocked_missing_player_role_context_count { + if actual.packed_event_blocked_missing_player_role_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_player_role_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_player_role_context_count + )); + } + } if let Some(count) = self.packed_event_blocked_missing_condition_context_count { if actual.packed_event_blocked_missing_condition_context_count != count { mismatches.push(format!( @@ -449,6 +493,14 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_missing_player_condition_context_count { + if actual.packed_event_blocked_missing_player_condition_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_player_condition_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_player_condition_context_count + )); + } + } if let Some(count) = self.packed_event_blocked_company_condition_scope_disabled_count { if actual.packed_event_blocked_company_condition_scope_disabled_count != count { mismatches.push(format!( @@ -513,6 +565,14 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_territory_policy_descriptor_count { + if actual.packed_event_blocked_territory_policy_descriptor_count != count { + mismatches.push(format!( + "packed_event_blocked_territory_policy_descriptor_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_territory_policy_descriptor_count + )); + } + } if let Some(count) = self.packed_event_blocked_structural_only_count { if actual.packed_event_blocked_structural_only_count != count { mismatches.push(format!( diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index 9c1dc4a..46570ea 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -11,10 +11,10 @@ use crate::{ RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, - RuntimePlayerConditionTestScope, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, - RuntimeWorldRestoreState, - SmpLoadedPackedEventNegativeSentinelScopeSummary, SmpLoadedPackedEventRecordSummary, - SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice, + RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeSaveProfileState, + RuntimeServiceState, RuntimeState, RuntimeTerritoryTarget, RuntimeWorldRestoreState, + SmpLoadedPackedEventConditionRowSummary, SmpLoadedPackedEventNegativeSentinelScopeSummary, + SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice, }; pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; @@ -103,34 +103,44 @@ enum SaveSliceProjectionMode { } #[derive(Debug, Clone, PartialEq, Eq)] -struct ImportCompanyContext { +struct ImportRuntimeContext { known_company_ids: BTreeSet, selected_company_id: Option, - has_complete_controller_context: bool, + has_complete_company_controller_context: bool, + known_player_ids: BTreeSet, + selected_player_id: Option, + has_complete_player_controller_context: bool, has_territory_context: bool, + territory_name_to_id: BTreeMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CompanyTargetImportBlocker { +enum ImportBlocker { MissingCompanyContext, MissingSelectionContext, MissingCompanyRoleContext, + MissingPlayerContext, + MissingPlayerSelectionContext, + MissingPlayerRoleContext, MissingConditionContext, + MissingPlayerConditionContext, CompanyConditionScopeDisabled, - PlayerConditionScope, - TerritoryConditionScope, MissingTerritoryContext, NamedTerritoryBinding, UnmappedOrdinaryCondition, } -impl ImportCompanyContext { +impl ImportRuntimeContext { fn standalone() -> Self { Self { known_company_ids: BTreeSet::new(), selected_company_id: None, - has_complete_controller_context: false, + has_complete_company_controller_context: false, + known_player_ids: BTreeSet::new(), + selected_player_id: None, + has_complete_player_controller_context: false, has_territory_context: false, + territory_name_to_id: BTreeMap::new(), } } @@ -142,11 +152,27 @@ impl ImportCompanyContext { .map(|company| company.company_id) .collect(), selected_company_id: state.selected_company_id, - has_complete_controller_context: !state.companies.is_empty() + has_complete_company_controller_context: !state.companies.is_empty() && state.companies.iter().all(|company| { company.controller_kind != RuntimeCompanyControllerKind::Unknown }), + known_player_ids: state.players.iter().map(|player| player.player_id).collect(), + selected_player_id: state.selected_player_id, + has_complete_player_controller_context: !state.players.is_empty() + && state.players.iter().all(|player| { + player.controller_kind != RuntimeCompanyControllerKind::Unknown + }), has_territory_context: !state.territories.is_empty(), + territory_name_to_id: state + .territories + .iter() + .filter_map(|territory| { + territory + .name + .as_ref() + .map(|name| (name.clone(), territory.territory_id)) + }) + .collect(), } } } @@ -161,7 +187,7 @@ pub fn project_save_slice_to_runtime_state_import( } let projection = project_save_slice_components( save_slice, - &ImportCompanyContext::standalone(), + &ImportRuntimeContext::standalone(), SaveSliceProjectionMode::Standalone, )?; @@ -178,6 +204,8 @@ pub fn project_save_slice_to_runtime_state_import( metadata: projection.metadata, companies: Vec::new(), selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: projection.packed_event_collection, @@ -206,7 +234,7 @@ pub fn project_save_slice_overlay_to_runtime_state_import( } base_state.validate()?; - let company_context = ImportCompanyContext::from_runtime_state(base_state); + let company_context = ImportRuntimeContext::from_runtime_state(base_state); let projection = project_save_slice_components( save_slice, &company_context, @@ -229,6 +257,8 @@ pub fn project_save_slice_overlay_to_runtime_state_import( metadata, companies: base_state.companies.clone(), selected_company_id: base_state.selected_company_id, + players: base_state.players.clone(), + selected_player_id: base_state.selected_player_id, territories: base_state.territories.clone(), company_territory_track_piece_counts: base_state .company_territory_track_piece_counts @@ -250,7 +280,7 @@ pub fn project_save_slice_overlay_to_runtime_state_import( fn project_save_slice_components( save_slice: &SmpLoadedSaveSlice, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, mode: SaveSliceProjectionMode, ) -> Result { let mut world_flags = BTreeMap::new(); @@ -547,7 +577,7 @@ fn project_save_slice_components( fn project_packed_event_collection( save_slice: &SmpLoadedSaveSlice, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, ) -> Result< ( Option, @@ -607,7 +637,7 @@ fn project_packed_event_collection( fn runtime_packed_event_record_summary_from_smp( record: &SmpLoadedPackedEventRecordSummary, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, imported: bool, ) -> RuntimePackedEventRecordSummary { let lowered_decoded_conditions = lowered_record_decoded_conditions(record, company_context) @@ -748,7 +778,7 @@ fn runtime_packed_event_grouped_effect_row_summary_from_smp( fn smp_packed_record_to_runtime_event_record( record: &SmpLoadedPackedEventRecordSummary, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, ) -> Option> { if record.decode_status == "unsupported_framing" { return None; @@ -771,6 +801,7 @@ fn smp_packed_record_to_runtime_event_record( &lowered_effects, company_context, conditions_provide_company_context(&lowered_conditions), + false, ) { Ok(effects) => effects, Err(_) => return None, @@ -801,47 +832,57 @@ fn smp_packed_record_to_runtime_event_record( fn lowered_record_decoded_conditions( record: &SmpLoadedPackedEventRecordSummary, - company_context: &ImportCompanyContext, -) -> Result, CompanyTargetImportBlocker> { + company_context: &ImportRuntimeContext, +) -> Result, ImportBlocker> { 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 + let lowered_company_target = lowered_condition_true_company_target(record)?; + let ordinary_rows = record + .standalone_condition_rows .iter() - .map(|condition| lower_condition_true_company_target_in_condition(condition, &lowered_target)) - .collect()) + .filter(|row| row.raw_condition_id >= 0); + ordinary_rows + .zip(record.decoded_conditions.iter()) + .map(|(row, condition)| { + lower_condition_targets_in_condition( + condition, + row, + lowered_company_target.as_ref(), + company_context, + ) + }) + .collect() } fn lowered_record_decoded_actions( record: &SmpLoadedPackedEventRecordSummary, - company_context: &ImportCompanyContext, -) -> Result, CompanyTargetImportBlocker> { + company_context: &ImportRuntimeContext, +) -> Result, ImportBlocker> { 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()); - }; - Ok(record + let lowered_company_target = lowered_condition_true_company_target(record)?; + let lowered_player_target = lowered_condition_true_player_target(record)?; + record .decoded_actions .iter() - .map(|effect| lower_condition_true_company_target_in_effect(effect, &lowered_target)) - .collect()) + .map(|effect| { + lower_condition_targets_in_effect( + effect, + lowered_company_target.as_ref(), + lowered_player_target.as_ref(), + ) + }) + .collect() } fn packed_record_condition_scope_import_blocker( record: &SmpLoadedPackedEventRecordSummary, - company_context: &ImportCompanyContext, -) -> Option { + company_context: &ImportRuntimeContext, +) -> Option { if record.standalone_condition_rows.is_empty() { return None; } @@ -852,23 +893,23 @@ fn packed_record_condition_scope_import_blocker( .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); + return Some(ImportBlocker::UnmappedOrdinaryCondition); } - if record - .decoded_conditions - .iter() - .any(|condition| matches!(condition, RuntimeCondition::TerritoryNumericThreshold { .. } | RuntimeCondition::CompanyTerritoryNumericThreshold { .. })) - && !company_context.has_territory_context + if (!company_context.has_territory_context) + && (record + .standalone_condition_rows + .iter() + .any(|row| row.requires_candidate_name_binding) + || record.decoded_conditions.iter().any(|condition| { + matches!( + condition, + RuntimeCondition::TerritoryNumericThreshold { .. } + | RuntimeCondition::CompanyTerritoryNumericThreshold { .. } + ) + })) { - return Some(CompanyTargetImportBlocker::MissingTerritoryContext); + return Some(ImportBlocker::MissingTerritoryContext); } } @@ -879,7 +920,7 @@ fn packed_record_condition_scope_import_blocker( .count(); if negative_sentinel_row_count == 0 { return if ordinary_condition_row_count == 0 { - Some(CompanyTargetImportBlocker::MissingConditionContext) + Some(ImportBlocker::MissingConditionContext) } else { None }; @@ -887,32 +928,11 @@ fn packed_record_condition_scope_import_blocker( if ordinary_condition_row_count == 0 && negative_sentinel_row_count != record.standalone_condition_rows.len() { - return Some(CompanyTargetImportBlocker::MissingConditionContext); + return Some(ImportBlocker::MissingConditionContext); } - let Some(scope) = record.negative_sentinel_scope.as_ref() else { - return Some(CompanyTargetImportBlocker::MissingConditionContext); - }; - if scope.player_test_scope != RuntimePlayerConditionTestScope::Disabled { - return Some(CompanyTargetImportBlocker::PlayerConditionScope); - } - if ordinary_condition_row_count == 0 && scope.territory_scope_selector_is_0x63 { - return Some(CompanyTargetImportBlocker::TerritoryConditionScope); - } - 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); + if record.negative_sentinel_scope.is_none() { + return Some(ImportBlocker::MissingConditionContext); } None @@ -920,54 +940,107 @@ fn packed_record_condition_scope_import_blocker( fn lowered_condition_true_company_target( record: &SmpLoadedPackedEventRecordSummary, -) -> Option { - let scope = record.negative_sentinel_scope.as_ref()?; +) -> Result, ImportBlocker> { + if !record_uses_condition_true_company(record) { + return Ok(None); + } + let scope = record + .negative_sentinel_scope + .as_ref() + .ok_or(ImportBlocker::MissingConditionContext)?; match scope.company_test_scope { - RuntimeCompanyConditionTestScope::Disabled => None, - RuntimeCompanyConditionTestScope::AllCompanies => Some(RuntimeCompanyTarget::AllActive), + RuntimeCompanyConditionTestScope::Disabled => { + Err(ImportBlocker::CompanyConditionScopeDisabled) + } + RuntimeCompanyConditionTestScope::AllCompanies => Ok(Some(RuntimeCompanyTarget::AllActive)), RuntimeCompanyConditionTestScope::SelectedCompanyOnly => { - Some(RuntimeCompanyTarget::SelectedCompany) + Ok(Some(RuntimeCompanyTarget::SelectedCompany)) } RuntimeCompanyConditionTestScope::AiCompaniesOnly => { - Some(RuntimeCompanyTarget::AiCompanies) + Ok(Some(RuntimeCompanyTarget::AiCompanies)) } RuntimeCompanyConditionTestScope::HumanCompaniesOnly => { - Some(RuntimeCompanyTarget::HumanCompanies) + Ok(Some(RuntimeCompanyTarget::HumanCompanies)) } } } -fn lower_condition_true_company_target_in_effect( +fn lowered_condition_true_player_target( + record: &SmpLoadedPackedEventRecordSummary, +) -> Result, ImportBlocker> { + if !record_uses_condition_true_player(record) { + return Ok(None); + } + let scope = record + .negative_sentinel_scope + .as_ref() + .ok_or(ImportBlocker::MissingPlayerConditionContext)?; + match scope.player_test_scope { + RuntimePlayerConditionTestScope::Disabled => Err(ImportBlocker::MissingPlayerConditionContext), + RuntimePlayerConditionTestScope::AllPlayers => Ok(Some(RuntimePlayerTarget::AllActive)), + RuntimePlayerConditionTestScope::SelectedPlayerOnly => { + Ok(Some(RuntimePlayerTarget::SelectedPlayer)) + } + RuntimePlayerConditionTestScope::AiPlayersOnly => { + Ok(Some(RuntimePlayerTarget::AiPlayers)) + } + RuntimePlayerConditionTestScope::HumanPlayersOnly => { + Ok(Some(RuntimePlayerTarget::HumanPlayers)) + } + } +} + +fn lower_condition_targets_in_effect( effect: &RuntimeEffect, - lowered_target: &RuntimeCompanyTarget, -) -> RuntimeEffect { - match effect { + lowered_company_target: Option<&RuntimeCompanyTarget>, + lowered_player_target: Option<&RuntimePlayerTarget>, +) -> Result { + Ok(match effect { RuntimeEffect::SetWorldFlag { key, value } => RuntimeEffect::SetWorldFlag { key: key.clone(), value: *value, }, RuntimeEffect::SetCompanyCash { target, value } => RuntimeEffect::SetCompanyCash { - target: lower_condition_true_company_target_in_company_target(target, lowered_target), + target: lower_condition_true_company_target_in_company_target( + target, + lowered_company_target, + )?, + value: *value, + }, + RuntimeEffect::SetPlayerCash { target, value } => RuntimeEffect::SetPlayerCash { + target: lower_condition_true_player_target_in_player_target( + target, + lowered_player_target, + )?, value: *value, }, RuntimeEffect::DeactivateCompany { target } => RuntimeEffect::DeactivateCompany { - target: lower_condition_true_company_target_in_company_target(target, lowered_target), + target: lower_condition_true_company_target_in_company_target( + target, + lowered_company_target, + )?, }, RuntimeEffect::SetCompanyTrackLayingCapacity { target, value } => { RuntimeEffect::SetCompanyTrackLayingCapacity { target: lower_condition_true_company_target_in_company_target( target, - lowered_target, - ), + lowered_company_target, + )?, value: *value, } } RuntimeEffect::AdjustCompanyCash { target, delta } => RuntimeEffect::AdjustCompanyCash { - target: lower_condition_true_company_target_in_company_target(target, lowered_target), + target: lower_condition_true_company_target_in_company_target( + target, + lowered_company_target, + )?, delta: *delta, }, RuntimeEffect::AdjustCompanyDebt { target, delta } => RuntimeEffect::AdjustCompanyDebt { - target: lower_condition_true_company_target_in_company_target(target, lowered_target), + target: lower_condition_true_company_target_in_company_target( + target, + lowered_company_target, + )?, delta: *delta, }, RuntimeEffect::SetCandidateAvailability { name, value } => { @@ -992,9 +1065,13 @@ fn lower_condition_true_company_target_in_effect( .effects .iter() .map(|nested| { - lower_condition_true_company_target_in_effect(nested, lowered_target) + lower_condition_targets_in_effect( + nested, + lowered_company_target, + lowered_player_target, + ) }) - .collect(), + .collect::, _>>()?, }), }, RuntimeEffect::ActivateEventRecord { record_id } => RuntimeEffect::ActivateEventRecord { @@ -1008,62 +1085,133 @@ fn lower_condition_true_company_target_in_effect( RuntimeEffect::RemoveEventRecord { record_id } => RuntimeEffect::RemoveEventRecord { record_id: *record_id, }, - } + }) } -fn lower_condition_true_company_target_in_condition( +fn lower_condition_targets_in_condition( condition: &RuntimeCondition, - lowered_target: &RuntimeCompanyTarget, -) -> RuntimeCondition { - match condition { + row: &SmpLoadedPackedEventConditionRowSummary, + lowered_company_target: Option<&RuntimeCompanyTarget>, + company_context: &ImportRuntimeContext, +) -> Result { + Ok(match condition { RuntimeCondition::CompanyNumericThreshold { target, metric, comparator, value, } => RuntimeCondition::CompanyNumericThreshold { - target: lower_condition_true_company_target_in_company_target(target, lowered_target), + target: lower_condition_true_company_target_in_company_target( + target, + lowered_company_target, + )?, metric: *metric, comparator: *comparator, value: *value, }, RuntimeCondition::TerritoryNumericThreshold { + target, metric, comparator, value, } => RuntimeCondition::TerritoryNumericThreshold { + target: lower_territory_target_in_condition(target, row, company_context)?, metric: *metric, comparator: *comparator, value: *value, }, RuntimeCondition::CompanyTerritoryNumericThreshold { target, + territory, metric, comparator, value, } => RuntimeCondition::CompanyTerritoryNumericThreshold { - target: lower_condition_true_company_target_in_company_target(target, lowered_target), + target: lower_condition_true_company_target_in_company_target( + target, + lowered_company_target, + )?, + territory: lower_territory_target_in_condition(territory, row, company_context)?, metric: *metric, comparator: *comparator, value: *value, }, - } + }) } fn lower_condition_true_company_target_in_company_target( target: &RuntimeCompanyTarget, - lowered_target: &RuntimeCompanyTarget, -) -> RuntimeCompanyTarget { + lowered_target: Option<&RuntimeCompanyTarget>, +) -> Result { match target { - RuntimeCompanyTarget::ConditionTrueCompany => lowered_target.clone(), - _ => target.clone(), + RuntimeCompanyTarget::ConditionTrueCompany => lowered_target + .cloned() + .ok_or(ImportBlocker::MissingConditionContext), + _ => Ok(target.clone()), + } +} + +fn lower_condition_true_player_target_in_player_target( + target: &RuntimePlayerTarget, + lowered_target: Option<&RuntimePlayerTarget>, +) -> Result { + match target { + RuntimePlayerTarget::ConditionTruePlayer => lowered_target + .cloned() + .ok_or(ImportBlocker::MissingPlayerConditionContext), + _ => Ok(target.clone()), + } +} + +fn lower_territory_target_in_condition( + target: &RuntimeTerritoryTarget, + row: &SmpLoadedPackedEventConditionRowSummary, + company_context: &ImportRuntimeContext, +) -> Result { + if !company_context.has_territory_context { + return Err(ImportBlocker::MissingTerritoryContext); + } + if !row.requires_candidate_name_binding { + return Ok(target.clone()); + } + let candidate_name = row + .candidate_name + .as_ref() + .ok_or(ImportBlocker::NamedTerritoryBinding)?; + let territory_id = company_context + .territory_name_to_id + .get(candidate_name) + .copied() + .ok_or(ImportBlocker::NamedTerritoryBinding)?; + Ok(RuntimeTerritoryTarget::Ids { + ids: vec![territory_id], + }) +} + +fn record_uses_condition_true_company(record: &SmpLoadedPackedEventRecordSummary) -> bool { + record.decoded_conditions.iter().any(condition_uses_condition_true_company) + || record.decoded_actions.iter().any(runtime_effect_uses_condition_true_company) +} + +fn record_uses_condition_true_player(record: &SmpLoadedPackedEventRecordSummary) -> bool { + record.decoded_actions.iter().any(runtime_effect_uses_condition_true_player) +} + +fn condition_uses_condition_true_company(condition: &RuntimeCondition) -> bool { + match condition { + RuntimeCondition::CompanyNumericThreshold { target, .. } + | RuntimeCondition::CompanyTerritoryNumericThreshold { target, .. } => { + matches!(target, RuntimeCompanyTarget::ConditionTrueCompany) + } + RuntimeCondition::TerritoryNumericThreshold { .. } => false, } } fn smp_runtime_effects_to_runtime_effects( effects: &[RuntimeEffect], - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, allow_condition_true_company: bool, + allow_condition_true_player: bool, ) -> Result, String> { effects .iter() @@ -1072,6 +1220,7 @@ fn smp_runtime_effects_to_runtime_effects( effect, company_context, allow_condition_true_company, + allow_condition_true_player, ) }) .collect() @@ -1079,8 +1228,9 @@ fn smp_runtime_effects_to_runtime_effects( fn smp_runtime_effect_to_runtime_effect( effect: &RuntimeEffect, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, allow_condition_true_company: bool, + allow_condition_true_player: bool, ) -> Result { match effect { RuntimeEffect::SetWorldFlag { key, value } => Ok(RuntimeEffect::SetWorldFlag { @@ -1101,6 +1251,20 @@ fn smp_runtime_effect_to_runtime_effect( Err(company_target_import_error_message(target, company_context)) } } + RuntimeEffect::SetPlayerCash { target, value } => { + if player_target_allowed_for_import( + target, + company_context, + allow_condition_true_player, + ) { + Ok(RuntimeEffect::SetPlayerCash { + target: target.clone(), + value: *value, + }) + } else { + Err(player_target_import_error_message(target, company_context)) + } + } RuntimeEffect::DeactivateCompany { target } => { if company_target_allowed_for_import( target, @@ -1173,6 +1337,7 @@ fn smp_runtime_effect_to_runtime_effect( record, company_context, allow_condition_true_company, + allow_condition_true_player, )?), }), RuntimeEffect::ActivateEventRecord { record_id } => { @@ -1193,8 +1358,9 @@ fn smp_runtime_effect_to_runtime_effect( fn smp_runtime_record_template_to_runtime( record: &RuntimeEventRecordTemplate, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, allow_condition_true_company: bool, + allow_condition_true_player: bool, ) -> Result { Ok(RuntimeEventRecordTemplate { record_id: record.record_id, @@ -1207,18 +1373,19 @@ fn smp_runtime_record_template_to_runtime( &record.effects, company_context, allow_condition_true_company, + allow_condition_true_player, )?, }) } fn company_target_allowed_for_import( target: &RuntimeCompanyTarget, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, allow_condition_true_company: bool, ) -> bool { match company_target_import_blocker(target, company_context) { None => true, - Some(CompanyTargetImportBlocker::MissingConditionContext) + Some(ImportBlocker::MissingConditionContext) if allow_condition_true_company && matches!(target, RuntimeCompanyTarget::ConditionTrueCompany) => { @@ -1228,6 +1395,23 @@ fn company_target_allowed_for_import( } } +fn player_target_allowed_for_import( + target: &RuntimePlayerTarget, + company_context: &ImportRuntimeContext, + allow_condition_true_player: bool, +) -> bool { + match player_target_import_blocker(target, company_context) { + None => true, + Some(ImportBlocker::MissingPlayerConditionContext) + if allow_condition_true_player + && matches!(target, RuntimePlayerTarget::ConditionTruePlayer) => + { + true + } + Some(_) => false, + } +} + fn conditions_provide_company_context(conditions: &[RuntimeCondition]) -> bool { conditions.iter().any(|condition| { matches!( @@ -1240,8 +1424,8 @@ fn conditions_provide_company_context(conditions: &[RuntimeCondition]) -> bool { fn company_target_import_blocker( target: &RuntimeCompanyTarget, - company_context: &ImportCompanyContext, -) -> Option { + company_context: &ImportRuntimeContext, +) -> Option { match target { RuntimeCompanyTarget::AllActive => None, RuntimeCompanyTarget::Ids { ids } => { @@ -1250,14 +1434,14 @@ fn company_target_import_blocker( .iter() .any(|company_id| !company_context.known_company_ids.contains(company_id)) { - Some(CompanyTargetImportBlocker::MissingCompanyContext) + Some(ImportBlocker::MissingCompanyContext) } else { None } } RuntimeCompanyTarget::HumanCompanies | RuntimeCompanyTarget::AiCompanies => { - if !company_context.has_complete_controller_context { - Some(CompanyTargetImportBlocker::MissingCompanyRoleContext) + if !company_context.has_complete_company_controller_context { + Some(ImportBlocker::MissingCompanyRoleContext) } else { None } @@ -1266,60 +1450,118 @@ fn company_target_import_blocker( if company_context.selected_company_id.is_some() { None } else { - Some(CompanyTargetImportBlocker::MissingSelectionContext) + Some(ImportBlocker::MissingSelectionContext) } } - RuntimeCompanyTarget::ConditionTrueCompany => { - Some(CompanyTargetImportBlocker::MissingConditionContext) - } + RuntimeCompanyTarget::ConditionTrueCompany => Some(ImportBlocker::MissingConditionContext), } } fn company_target_import_error_message( target: &RuntimeCompanyTarget, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, ) -> String { match company_target_import_blocker(target, company_context) { - Some(CompanyTargetImportBlocker::MissingCompanyContext) => { + Some(ImportBlocker::MissingCompanyContext) => { "packed company effect requires resolved company ids".to_string() } - Some(CompanyTargetImportBlocker::MissingSelectionContext) => { + Some(ImportBlocker::MissingSelectionContext) => { "packed company effect requires selected_company_id context".to_string() } - Some(CompanyTargetImportBlocker::MissingCompanyRoleContext) => { + Some(ImportBlocker::MissingCompanyRoleContext) => { "packed company effect requires company controller role context".to_string() } - Some(CompanyTargetImportBlocker::MissingConditionContext) => { + Some(ImportBlocker::MissingConditionContext) => { "packed company effect requires condition-relative context".to_string() } - Some(CompanyTargetImportBlocker::CompanyConditionScopeDisabled) => { + Some(ImportBlocker::CompanyConditionScopeDisabled) => { "packed company effect disables company-side negative-sentinel condition scope" .to_string() } - Some(CompanyTargetImportBlocker::PlayerConditionScope) => { - "packed company effect requires player runtime ownership for negative-sentinel scope" - .to_string() - } - Some(CompanyTargetImportBlocker::TerritoryConditionScope) => { - "packed company effect requires territory runtime ownership for negative-sentinel scope" - .to_string() - } - Some(CompanyTargetImportBlocker::MissingTerritoryContext) => { + Some(ImportBlocker::MissingTerritoryContext) => { "packed condition requires territory runtime context".to_string() } - Some(CompanyTargetImportBlocker::NamedTerritoryBinding) => { + Some(ImportBlocker::NamedTerritoryBinding) => { "packed condition requires named territory binding".to_string() } - Some(CompanyTargetImportBlocker::UnmappedOrdinaryCondition) => { + Some(ImportBlocker::UnmappedOrdinaryCondition) => { "packed ordinary condition is not yet mapped".to_string() } + Some(ImportBlocker::MissingPlayerContext) + | Some(ImportBlocker::MissingPlayerSelectionContext) + | Some(ImportBlocker::MissingPlayerRoleContext) + | Some(ImportBlocker::MissingPlayerConditionContext) => { + "packed company effect is blocked by non-company import context".to_string() + } None => "packed company effect is importable".to_string(), } } +fn player_target_import_blocker( + target: &RuntimePlayerTarget, + company_context: &ImportRuntimeContext, +) -> Option { + match target { + RuntimePlayerTarget::AllActive => { + if company_context.known_player_ids.is_empty() { + Some(ImportBlocker::MissingPlayerContext) + } else { + None + } + } + RuntimePlayerTarget::Ids { ids } => { + if ids.is_empty() + || ids + .iter() + .any(|player_id| !company_context.known_player_ids.contains(player_id)) + { + Some(ImportBlocker::MissingPlayerContext) + } else { + None + } + } + RuntimePlayerTarget::HumanPlayers | RuntimePlayerTarget::AiPlayers => { + if !company_context.has_complete_player_controller_context { + Some(ImportBlocker::MissingPlayerRoleContext) + } else { + None + } + } + RuntimePlayerTarget::SelectedPlayer => { + if company_context.selected_player_id.is_some() { + None + } else { + Some(ImportBlocker::MissingPlayerSelectionContext) + } + } + RuntimePlayerTarget::ConditionTruePlayer => Some(ImportBlocker::MissingPlayerConditionContext), + } +} + +fn player_target_import_error_message( + target: &RuntimePlayerTarget, + company_context: &ImportRuntimeContext, +) -> String { + match player_target_import_blocker(target, company_context) { + Some(ImportBlocker::MissingPlayerContext) => { + "packed player effect requires resolved player ids".to_string() + } + Some(ImportBlocker::MissingPlayerSelectionContext) => { + "packed player effect requires selected_player_id context".to_string() + } + Some(ImportBlocker::MissingPlayerRoleContext) => { + "packed player effect requires player controller role context".to_string() + } + Some(ImportBlocker::MissingPlayerConditionContext) => { + "packed player effect requires player condition-relative context".to_string() + } + _ => "packed player effect is importable".to_string(), + } +} + fn determine_packed_event_import_outcome( record: &SmpLoadedPackedEventRecordSummary, - company_context: &ImportCompanyContext, + company_context: &ImportRuntimeContext, imported: bool, ) -> String { if imported { @@ -1333,6 +1575,13 @@ fn determine_packed_event_import_outcome( return "blocked_missing_compact_control".to_string(); } if !record.executable_import_ready { + if record + .grouped_effect_rows + .iter() + .any(|row| row.descriptor_id == 3) + { + return "blocked_territory_policy_descriptor".to_string(); + } return if record .standalone_condition_rows .iter() @@ -1361,30 +1610,23 @@ fn determine_packed_event_import_outcome( fn packed_record_company_target_import_blocker( record: &SmpLoadedPackedEventRecordSummary, - company_context: &ImportCompanyContext, -) -> Option { + company_context: &ImportRuntimeContext, +) -> Option { if record .decoded_actions .iter() - .any(runtime_effect_uses_condition_true_company) - && !record - .decoded_conditions - .iter() - .any(|condition| matches!(condition, RuntimeCondition::CompanyNumericThreshold { .. } | RuntimeCondition::CompanyTerritoryNumericThreshold { .. })) + .any(|effect| { + runtime_effect_uses_condition_true_company(effect) + || runtime_effect_uses_condition_true_player(effect) + }) + && record.negative_sentinel_scope.is_none() { - return Some(CompanyTargetImportBlocker::MissingConditionContext); + return Some(ImportBlocker::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) }) { @@ -1396,28 +1638,83 @@ fn packed_record_company_target_import_blocker( }; lowered_effects .iter() - .find_map(|effect| { - runtime_effect_company_target_import_blocker( - effect, - company_context, - has_company_condition_context, - ) - }) + .find_map(|effect| runtime_effect_company_target_import_blocker(effect, company_context)) } fn runtime_condition_company_target_import_blocker( condition: &RuntimeCondition, - company_context: &ImportCompanyContext, -) -> Option { + company_context: &ImportRuntimeContext, +) -> Option { match condition { - RuntimeCondition::CompanyNumericThreshold { target, .. } - | RuntimeCondition::CompanyTerritoryNumericThreshold { target, .. } => { + RuntimeCondition::CompanyNumericThreshold { target, .. } => { company_target_import_blocker(target, company_context) } - RuntimeCondition::TerritoryNumericThreshold { .. } => None, + RuntimeCondition::TerritoryNumericThreshold { target, .. } => { + territory_target_import_blocker(target, company_context) + } + RuntimeCondition::CompanyTerritoryNumericThreshold { + target, territory, .. + } => company_target_import_blocker(target, company_context) + .or_else(|| territory_target_import_blocker(territory, company_context)), } } +fn territory_target_import_blocker( + target: &RuntimeTerritoryTarget, + company_context: &ImportRuntimeContext, +) -> Option { + if !company_context.has_territory_context { + return Some(ImportBlocker::MissingTerritoryContext); + } + match target { + RuntimeTerritoryTarget::AllTerritories => None, + RuntimeTerritoryTarget::Ids { ids } => { + if ids.is_empty() { + Some(ImportBlocker::NamedTerritoryBinding) + } else if !territory_ids_match_known_names(ids, company_context) + { + Some(ImportBlocker::NamedTerritoryBinding) + } else { + None + } + } + } +} + +fn company_target_import_outcome(blocker: ImportBlocker) -> &'static str { + match blocker { + ImportBlocker::MissingCompanyContext => "blocked_missing_company_context", + ImportBlocker::MissingSelectionContext => "blocked_missing_selection_context", + ImportBlocker::MissingCompanyRoleContext => "blocked_missing_company_role_context", + ImportBlocker::MissingPlayerContext => "blocked_missing_player_context", + ImportBlocker::MissingPlayerSelectionContext => { + "blocked_missing_player_selection_context" + } + ImportBlocker::MissingPlayerRoleContext => "blocked_missing_player_role_context", + ImportBlocker::MissingConditionContext => "blocked_missing_condition_context", + ImportBlocker::MissingPlayerConditionContext => { + "blocked_missing_player_condition_context" + } + ImportBlocker::CompanyConditionScopeDisabled => { + "blocked_company_condition_scope_disabled" + } + ImportBlocker::MissingTerritoryContext => "blocked_missing_territory_context", + ImportBlocker::NamedTerritoryBinding => "blocked_named_territory_binding", + ImportBlocker::UnmappedOrdinaryCondition => "blocked_unmapped_ordinary_condition", + } +} + +fn territory_ids_match_known_names( + ids: &[u32], + company_context: &ImportRuntimeContext, +) -> bool { + ids.iter().all(|territory_id| { + company_context + .territory_name_to_id + .values() + .any(|id| id == territory_id) + }) +} fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool { match effect { RuntimeEffect::SetCompanyCash { target, .. } @@ -1432,6 +1729,7 @@ fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool { .iter() .any(runtime_effect_uses_condition_true_company), RuntimeEffect::SetWorldFlag { .. } + | RuntimeEffect::SetPlayerCash { .. } | RuntimeEffect::SetCandidateAvailability { .. } | RuntimeEffect::SetSpecialCondition { .. } | RuntimeEffect::ActivateEventRecord { .. } @@ -1440,30 +1738,36 @@ fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool { } } +fn runtime_effect_uses_condition_true_player(effect: &RuntimeEffect) -> bool { + match effect { + RuntimeEffect::SetPlayerCash { target, .. } => { + matches!(target, RuntimePlayerTarget::ConditionTruePlayer) + } + RuntimeEffect::AppendEventRecord { record } => record + .effects + .iter() + .any(runtime_effect_uses_condition_true_player), + _ => false, + } +} + fn runtime_effect_company_target_import_blocker( effect: &RuntimeEffect, - company_context: &ImportCompanyContext, - allow_condition_true_company: bool, -) -> Option { + company_context: &ImportRuntimeContext, +) -> Option { match effect { RuntimeEffect::SetCompanyCash { target, .. } | RuntimeEffect::DeactivateCompany { target } | 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::SetPlayerCash { target, .. } => { + player_target_import_blocker(target, company_context) + } RuntimeEffect::AppendEventRecord { record } => record.effects.iter().find_map(|nested| { - runtime_effect_company_target_import_blocker( - nested, - company_context, - allow_condition_true_company, - ) + runtime_effect_company_target_import_blocker(nested, company_context) }), RuntimeEffect::SetWorldFlag { .. } | RuntimeEffect::SetCandidateAvailability { .. } @@ -1508,27 +1812,6 @@ fn classify_real_grouped_company_target(ordinal: u8) -> Option &'static str { - match blocker { - CompanyTargetImportBlocker::MissingCompanyContext => "blocked_missing_company_context", - CompanyTargetImportBlocker::MissingSelectionContext => "blocked_missing_selection_context", - CompanyTargetImportBlocker::MissingCompanyRoleContext => { - "blocked_missing_company_role_context" - } - CompanyTargetImportBlocker::MissingConditionContext => "blocked_missing_condition_context", - CompanyTargetImportBlocker::CompanyConditionScopeDisabled => { - "blocked_company_condition_scope_disabled" - } - 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" - } - } -} - pub fn validate_runtime_state_dump_document( document: &RuntimeStateDumpDocument, ) -> Result<(), String> { @@ -1817,6 +2100,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -3286,7 +3571,7 @@ mod tests { } #[test] - fn blocks_negative_sentinel_player_scope_until_player_runtime_exists() { + fn blocks_player_scoped_effects_without_player_runtime_context() { let save_slice = SmpLoadedSaveSlice { file_extension_hint: Some("gms".to_string()), container_profile_family: Some("rt3-classic-save-container-v1".to_string()), @@ -3331,8 +3616,8 @@ 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, + decoded_actions: vec![RuntimeEffect::SetPlayerCash { + target: RuntimePlayerTarget::ConditionTruePlayer, value: 7, }], executable_import_ready: true, @@ -3356,12 +3641,12 @@ mod tests { .packed_event_collection .as_ref() .and_then(|summary| summary.records[0].import_outcome.as_deref()), - Some("blocked_player_condition_scope") + Some("blocked_missing_player_context") ); } #[test] - fn blocks_negative_sentinel_territory_scope_until_territory_runtime_exists() { + fn blocks_named_or_aggregate_territory_conditions_without_territory_context() { let save_slice = SmpLoadedSaveSlice { file_extension_hint: Some("gms".to_string()), container_profile_family: Some("rt3-classic-save-container-v1".to_string()), @@ -3401,11 +3686,28 @@ mod tests { compact_control: Some(real_compact_control()), text_bands: packed_text_bands(), standalone_condition_row_count: 1, - standalone_condition_rows: real_condition_rows(), + standalone_condition_rows: vec![crate::SmpLoadedPackedEventConditionRowSummary { + row_index: 0, + raw_condition_id: 2313, + subtype: 0, + flag_bytes: vec![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: None, + comparator: Some("ge".to_string()), + metric: Some("Territory Track Pieces".to_string()), + semantic_family: Some("numeric_threshold".to_string()), + semantic_preview: Some("Test Territory Track Pieces >= 10".to_string()), + requires_candidate_name_binding: false, + notes: Vec::new(), + }], 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_conditions: vec![RuntimeCondition::TerritoryNumericThreshold { + target: RuntimeTerritoryTarget::AllTerritories, + metric: crate::RuntimeTerritoryMetric::TrackPiecesTotal, + comparator: crate::RuntimeConditionComparator::Ge, + value: 10, + }], decoded_actions: vec![RuntimeEffect::SetCompanyCash { target: RuntimeCompanyTarget::ConditionTrueCompany, value: 7, @@ -3431,7 +3733,7 @@ mod tests { .packed_event_collection .as_ref() .and_then(|summary| summary.records[0].import_outcome.as_deref()), - Some("blocked_territory_condition_scope") + Some("blocked_missing_territory_context") ); } @@ -3532,6 +3834,8 @@ mod tests { available_track_laying_capacity: None, }], selected_company_id: Some(42), + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -4077,6 +4381,8 @@ mod tests { available_track_laying_capacity: None, }], selected_company_id: Some(42), + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -4249,6 +4555,8 @@ mod tests { available_track_laying_capacity: None, }], selected_company_id: Some(42), + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index 680d529..0537d40 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -41,9 +41,10 @@ pub use runtime::{ RuntimeEventRecordTemplate, RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, - RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, - RuntimePlayerConditionTestScope, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, - RuntimeTerritory, RuntimeTerritoryMetric, RuntimeTrackMetric, RuntimeTrackPieceCounts, + RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimePlayer, + RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeSaveProfileState, + RuntimeServiceState, RuntimeState, RuntimeTerritory, RuntimeTerritoryMetric, + RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, RuntimeWorldRestoreState, }; pub use smp::{ diff --git a/crates/rrt-runtime/src/persistence.rs b/crates/rrt-runtime/src/persistence.rs index 0dd3569..f5f37de 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, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index f26b205..6d8d8fa 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -56,6 +56,8 @@ pub struct RuntimeTrackPieceCounts { pub struct RuntimeTerritory { pub territory_id: u32, #[serde(default)] + pub name: Option, + #[serde(default)] pub track_piece_counts: RuntimeTrackPieceCounts, } @@ -67,6 +69,20 @@ pub struct RuntimeCompanyTerritoryTrackPieceCount { pub track_piece_counts: RuntimeTrackPieceCounts, } +fn runtime_player_default_active() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimePlayer { + pub player_id: u32, + pub current_cash: i64, + #[serde(default = "runtime_player_default_active")] + pub active: bool, + #[serde(default)] + pub controller_kind: RuntimeCompanyControllerKind, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RuntimeCompanyTarget { @@ -78,6 +94,24 @@ pub enum RuntimeCompanyTarget { Ids { ids: Vec }, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuntimePlayerTarget { + AllActive, + HumanPlayers, + AiPlayers, + SelectedPlayer, + ConditionTruePlayer, + Ids { ids: Vec }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RuntimeTerritoryTarget { + AllTerritories, + Ids { ids: Vec }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum RuntimeCompanyConditionTestScope { @@ -158,12 +192,14 @@ pub enum RuntimeCondition { value: i64, }, TerritoryNumericThreshold { + target: RuntimeTerritoryTarget, metric: RuntimeTerritoryMetric, comparator: RuntimeConditionComparator, value: i64, }, CompanyTerritoryNumericThreshold { target: RuntimeCompanyTarget, + territory: RuntimeTerritoryTarget, metric: RuntimeTrackMetric, comparator: RuntimeConditionComparator, value: i64, @@ -181,6 +217,10 @@ pub enum RuntimeEffect { target: RuntimeCompanyTarget, value: i64, }, + SetPlayerCash { + target: RuntimePlayerTarget, + value: i64, + }, DeactivateCompany { target: RuntimeCompanyTarget, }, @@ -508,6 +548,10 @@ pub struct RuntimeState { #[serde(default)] pub selected_company_id: Option, #[serde(default)] + pub players: Vec, + #[serde(default)] + pub selected_player_id: Option, + #[serde(default)] pub territories: Vec, #[serde(default)] pub company_territory_track_piece_counts: Vec, @@ -552,11 +596,48 @@ impl RuntimeState { } } + let mut seen_player_ids = BTreeSet::new(); + let mut active_player_ids = BTreeSet::new(); + for player in &self.players { + if !seen_player_ids.insert(player.player_id) { + return Err(format!("duplicate player_id {}", player.player_id)); + } + if player.active { + active_player_ids.insert(player.player_id); + } + } + if let Some(selected_player_id) = self.selected_player_id { + if !seen_player_ids.contains(&selected_player_id) { + return Err(format!( + "selected_player_id {} does not reference a live player", + selected_player_id + )); + } + if !active_player_ids.contains(&selected_player_id) { + return Err(format!( + "selected_player_id {} must reference an active player", + selected_player_id + )); + } + } + let mut seen_territory_ids = BTreeSet::new(); + let mut seen_territory_names = BTreeSet::new(); for territory in &self.territories { if !seen_territory_ids.insert(territory.territory_id) { return Err(format!("duplicate territory_id {}", territory.territory_id)); } + if let Some(name) = territory.name.as_deref() { + if name.trim().is_empty() { + return Err(format!( + "territory_id {} has an empty name", + territory.territory_id + )); + } + if !seen_territory_names.insert(name.to_string()) { + return Err(format!("duplicate territory name {name:?}")); + } + } } for entry in &self.company_territory_track_piece_counts { if !seen_company_ids.contains(&entry.company_id) { @@ -579,15 +660,22 @@ impl RuntimeState { 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| { + validate_runtime_condition(condition, &seen_company_ids, &seen_territory_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| { + validate_runtime_effect( + effect, + &seen_company_ids, + &seen_player_ids, + &seen_territory_ids, + ) + .map_err(|err| { format!( "event_runtime_records[record_id={}].effects[{effect_index}] {err}", record.record_id @@ -912,6 +1000,8 @@ impl RuntimeState { fn validate_runtime_effect( effect: &RuntimeEffect, valid_company_ids: &BTreeSet, + valid_player_ids: &BTreeSet, + valid_territory_ids: &BTreeSet, ) -> Result<(), String> { match effect { RuntimeEffect::SetWorldFlag { key, .. } => { @@ -926,6 +1016,9 @@ fn validate_runtime_effect( | RuntimeEffect::AdjustCompanyDebt { target, .. } => { validate_company_target(target, valid_company_ids)?; } + RuntimeEffect::SetPlayerCash { target, .. } => { + validate_player_target(target, valid_player_ids)?; + } RuntimeEffect::SetCandidateAvailability { name, .. } => { if name.trim().is_empty() { return Err("name must not be empty".to_string()); @@ -937,7 +1030,12 @@ fn validate_runtime_effect( } } RuntimeEffect::AppendEventRecord { record } => { - validate_event_record_template(record, valid_company_ids)?; + validate_event_record_template( + record, + valid_company_ids, + valid_player_ids, + valid_territory_ids, + )?; } RuntimeEffect::ActivateEventRecord { .. } | RuntimeEffect::DeactivateEventRecord { .. } @@ -950,17 +1048,27 @@ fn validate_runtime_effect( fn validate_event_record_template( record: &RuntimeEventRecordTemplate, valid_company_ids: &BTreeSet, + valid_player_ids: &BTreeSet, + valid_territory_ids: &BTreeSet, ) -> Result<(), String> { for (condition_index, condition) in record.conditions.iter().enumerate() { - validate_runtime_condition(condition, valid_company_ids).map_err(|err| { + validate_runtime_condition(condition, valid_company_ids, valid_territory_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| { + validate_runtime_effect( + effect, + valid_company_ids, + valid_player_ids, + valid_territory_ids, + ) + .map_err(|err| { format!( "template record_id={}.effects[{effect_index}] {err}", record.record_id @@ -974,13 +1082,23 @@ fn validate_event_record_template( fn validate_runtime_condition( condition: &RuntimeCondition, valid_company_ids: &BTreeSet, + valid_territory_ids: &BTreeSet, ) -> Result<(), String> { match condition { - RuntimeCondition::CompanyNumericThreshold { target, .. } - | RuntimeCondition::CompanyTerritoryNumericThreshold { target, .. } => { + RuntimeCondition::CompanyNumericThreshold { target, .. } => { validate_company_target(target, valid_company_ids) } - RuntimeCondition::TerritoryNumericThreshold { .. } => Ok(()), + RuntimeCondition::TerritoryNumericThreshold { target, .. } => { + validate_territory_target(target, valid_territory_ids) + } + RuntimeCondition::CompanyTerritoryNumericThreshold { + target, + territory, + .. + } => { + validate_company_target(target, valid_company_ids)?; + validate_territory_target(territory, valid_territory_ids) + } } } @@ -1008,6 +1126,52 @@ fn validate_company_target( } } +fn validate_player_target( + target: &RuntimePlayerTarget, + valid_player_ids: &BTreeSet, +) -> Result<(), String> { + match target { + RuntimePlayerTarget::AllActive + | RuntimePlayerTarget::HumanPlayers + | RuntimePlayerTarget::AiPlayers + | RuntimePlayerTarget::SelectedPlayer + | RuntimePlayerTarget::ConditionTruePlayer => Ok(()), + RuntimePlayerTarget::Ids { ids } => { + if ids.is_empty() { + return Err("target ids must not be empty".to_string()); + } + for player_id in ids { + if !valid_player_ids.contains(player_id) { + return Err(format!("target references unknown player_id {player_id}")); + } + } + Ok(()) + } + } +} + +fn validate_territory_target( + target: &RuntimeTerritoryTarget, + valid_territory_ids: &BTreeSet, +) -> Result<(), String> { + match target { + RuntimeTerritoryTarget::AllTerritories => Ok(()), + RuntimeTerritoryTarget::Ids { ids } => { + if ids.is_empty() { + return Err("territory target ids must not be empty".to_string()); + } + for territory_id in ids { + if !valid_territory_ids.contains(territory_id) { + return Err(format!( + "territory target references unknown territory_id {territory_id}" + )); + } + } + Ok(()) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1050,6 +1214,8 @@ mod tests { }, ], selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1099,6 +1265,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1136,6 +1304,8 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Unknown, }], selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1186,6 +1356,8 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Unknown, }], selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1236,6 +1408,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: Some(RuntimePackedEventCollectionSummary { @@ -1337,6 +1511,8 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, }], selected_company_id: Some(2), + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1374,6 +1550,8 @@ mod tests { controller_kind: RuntimeCompanyControllerKind::Human, }], selected_company_id: Some(1), + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index a2c4e01..7ceb2f4 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -7,7 +7,8 @@ use sha2::{Digest, Sha256}; use crate::{ RuntimeCompanyConditionTestScope, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, - RuntimePlayerConditionTestScope, RuntimeTerritoryMetric, RuntimeTrackMetric, + RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeTerritoryMetric, + RuntimeTerritoryTarget, RuntimeTrackMetric, }; pub const SMP_FOUR_SIDECAR_BYTE_PLANES_MIN_BUNDLE_VERSION: u32 = 0x03ec; @@ -132,7 +133,7 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad label: "Player Cash", target_mask_bits: 0x02, parameter_family: "player_finance_scalar", - executable_in_runtime: false, + executable_in_runtime: true, }, RealGroupedEffectDescriptorMetadata { descriptor_id: 2, @@ -2471,6 +2472,7 @@ fn decode_real_condition_row( negative_sentinel_scope .filter(|scope| scope.territory_scope_selector_is_0x63) .map(|_| RuntimeCondition::TerritoryNumericThreshold { + target: RuntimeTerritoryTarget::AllTerritories, metric, comparator, value, @@ -2481,6 +2483,7 @@ fn decode_real_condition_row( .filter(|scope| scope.territory_scope_selector_is_0x63) .map(|_| RuntimeCondition::CompanyTerritoryNumericThreshold { target: RuntimeCompanyTarget::ConditionTrueCompany, + territory: RuntimeTerritoryTarget::AllTerritories, metric, comparator, value, @@ -2588,19 +2591,25 @@ fn decode_real_grouped_effect_action( .grouped_target_scope_ordinals_0x7fb .get(row.group_index) .copied()?; - let target = match target_scope_ordinal { - 0 => RuntimeCompanyTarget::ConditionTrueCompany, - 1 => RuntimeCompanyTarget::SelectedCompany, - 2 => RuntimeCompanyTarget::HumanCompanies, - 3 => RuntimeCompanyTarget::AiCompanies, - _ => return None, - }; + + if descriptor_metadata.executable_in_runtime + && descriptor_metadata.descriptor_id == 1 + && row.opcode == 8 + && row.row_shape == "multivalue_scalar" + { + let target = real_grouped_player_target(target_scope_ordinal)?; + return Some(RuntimeEffect::SetPlayerCash { + target, + value: i64::from(row.raw_scalar_value), + }); + } if descriptor_metadata.executable_in_runtime && descriptor_metadata.descriptor_id == 2 && row.opcode == 8 && row.row_shape == "multivalue_scalar" { + let target = real_grouped_company_target(target_scope_ordinal)?; return Some(RuntimeEffect::SetCompanyCash { target, value: i64::from(row.raw_scalar_value), @@ -2612,6 +2621,7 @@ fn decode_real_grouped_effect_action( && row.row_shape == "bool_toggle" && row.raw_scalar_value != 0 { + let target = real_grouped_company_target(target_scope_ordinal)?; return Some(RuntimeEffect::DeactivateCompany { target }); } @@ -2620,6 +2630,7 @@ fn decode_real_grouped_effect_action( && row.row_shape == "scalar_assignment" && row.raw_scalar_value >= 0 { + let target = real_grouped_company_target(target_scope_ordinal)?; return Some(RuntimeEffect::SetCompanyTrackLayingCapacity { target, value: Some(row.raw_scalar_value as u32), @@ -2629,6 +2640,26 @@ fn decode_real_grouped_effect_action( None } +fn real_grouped_company_target(ordinal: u8) -> Option { + match ordinal { + 0 => Some(RuntimeCompanyTarget::ConditionTrueCompany), + 1 => Some(RuntimeCompanyTarget::SelectedCompany), + 2 => Some(RuntimeCompanyTarget::HumanCompanies), + 3 => Some(RuntimeCompanyTarget::AiCompanies), + _ => None, + } +} + +fn real_grouped_player_target(ordinal: u8) -> Option { + match ordinal { + 0 => Some(RuntimePlayerTarget::ConditionTruePlayer), + 1 => Some(RuntimePlayerTarget::SelectedPlayer), + 2 => Some(RuntimePlayerTarget::HumanPlayers), + 3 => Some(RuntimePlayerTarget::AiPlayers), + _ => None, + } +} + fn parse_synthetic_packed_event_action(bytes: &[u8], cursor: &mut usize) -> Option { let opcode = read_u8_at(bytes, *cursor)?; *cursor += 1; @@ -2784,6 +2815,15 @@ fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool { | RuntimeEffect::ActivateEventRecord { .. } | RuntimeEffect::DeactivateEventRecord { .. } | RuntimeEffect::RemoveEventRecord { .. } => true, + RuntimeEffect::SetPlayerCash { target, .. } => matches!( + target, + RuntimePlayerTarget::AllActive + | RuntimePlayerTarget::Ids { .. } + | RuntimePlayerTarget::HumanPlayers + | RuntimePlayerTarget::AiPlayers + | RuntimePlayerTarget::SelectedPlayer + | RuntimePlayerTarget::ConditionTruePlayer + ), RuntimeEffect::SetCompanyCash { target, .. } | RuntimeEffect::AdjustCompanyCash { target, .. } | RuntimeEffect::AdjustCompanyDebt { target, .. } => matches!( diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index 01a3eeb..828dd83 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -4,8 +4,9 @@ use serde::{Deserialize, Serialize}; use crate::{ RuntimeCompanyControllerKind, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, - RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, RuntimeState, - RuntimeSummary, RuntimeTerritoryMetric, RuntimeTrackMetric, RuntimeTrackPieceCounts, + RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerTarget, + RuntimeState, RuntimeSummary, RuntimeTerritoryMetric, RuntimeTerritoryTarget, + RuntimeTrackMetric, RuntimeTrackPieceCounts, calendar::BoundaryEventKind, }; @@ -48,6 +49,7 @@ pub struct ServiceEvent { pub serviced_record_ids: Vec, pub applied_effect_count: u32, pub mutated_company_ids: Vec, + pub mutated_player_ids: Vec, pub appended_record_ids: Vec, pub activated_record_ids: Vec, pub deactivated_record_ids: Vec, @@ -84,6 +86,7 @@ struct AppliedEffectsSummary { #[derive(Debug, Default)] struct ResolvedConditionContext { matching_company_ids: BTreeSet, + matching_player_ids: BTreeSet, } pub fn execute_step_command( @@ -205,6 +208,7 @@ fn service_trigger_kind( let mut serviced_record_ids = Vec::new(); let mut applied_effect_count = 0_u32; let mut mutated_company_ids = BTreeSet::new(); + let mut mutated_player_ids = BTreeSet::new(); let mut appended_record_ids = Vec::new(); let mut activated_record_ids = Vec::new(); let mut deactivated_record_ids = Vec::new(); @@ -245,6 +249,7 @@ fn service_trigger_kind( &record_effects, &condition_context, &mut mutated_company_ids, + &mut mutated_player_ids, &mut staged_event_graph_mutations, )?; applied_effect_count += effect_summary.applied_effect_count; @@ -276,6 +281,7 @@ fn service_trigger_kind( serviced_record_ids, applied_effect_count, mutated_company_ids: mutated_company_ids.into_iter().collect(), + mutated_player_ids: mutated_player_ids.into_iter().collect(), appended_record_ids, activated_record_ids, deactivated_record_ids, @@ -296,6 +302,7 @@ fn apply_runtime_effects( effects: &[RuntimeEffect], condition_context: &ResolvedConditionContext, mutated_company_ids: &mut BTreeSet, + mutated_player_ids: &mut BTreeSet, staged_event_graph_mutations: &mut Vec, ) -> Result { let mut summary = AppliedEffectsSummary::default(); @@ -319,6 +326,20 @@ fn apply_runtime_effects( mutated_company_ids.insert(company_id); } } + RuntimeEffect::SetPlayerCash { target, value } => { + let player_ids = resolve_player_target_ids(state, target, condition_context)?; + for player_id in player_ids { + let player = state + .players + .iter_mut() + .find(|player| player.player_id == player_id) + .ok_or_else(|| { + format!("missing player_id {player_id} while applying cash effect") + })?; + player.current_cash = *value; + mutated_player_ids.insert(player_id); + } + } RuntimeEffect::DeactivateCompany { target } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { @@ -520,21 +541,25 @@ fn evaluate_record_conditions( } } RuntimeCondition::TerritoryNumericThreshold { + target, metric, comparator, value, } => { - let actual = territory_metric_value(state, *metric); + let territory_ids = resolve_territory_target_ids(state, target)?; + let actual = territory_metric_value(state, &territory_ids, *metric); if !compare_condition_value(actual, *comparator, *value) { return Ok(None); } } RuntimeCondition::CompanyTerritoryNumericThreshold { target, + territory, metric, comparator, value, } => { + let territory_ids = resolve_territory_target_ids(state, territory)?; let resolved = resolve_company_target_ids( state, target, @@ -544,7 +569,12 @@ fn evaluate_record_conditions( .into_iter() .filter(|company_id| { compare_condition_value( - company_territory_metric_value(state, *company_id, *metric), + company_territory_metric_value( + state, + *company_id, + &territory_ids, + *metric, + ), *comparator, *value, ) @@ -563,6 +593,7 @@ fn evaluate_record_conditions( Ok(Some(ResolvedConditionContext { matching_company_ids: company_matches.unwrap_or_default(), + matching_player_ids: BTreeSet::new(), })) } @@ -676,6 +707,119 @@ fn resolve_company_target_ids( } } +fn resolve_player_target_ids( + state: &RuntimeState, + target: &RuntimePlayerTarget, + condition_context: &ResolvedConditionContext, +) -> Result, String> { + match target { + RuntimePlayerTarget::AllActive => Ok(state + .players + .iter() + .filter(|player| player.active) + .map(|player| player.player_id) + .collect()), + RuntimePlayerTarget::Ids { ids } => { + let known_ids = state + .players + .iter() + .map(|player| player.player_id) + .collect::>(); + for player_id in ids { + if !known_ids.contains(player_id) { + return Err(format!("target references unknown player_id {player_id}")); + } + } + Ok(ids.clone()) + } + RuntimePlayerTarget::HumanPlayers => { + if state + .players + .iter() + .any(|player| player.controller_kind == RuntimeCompanyControllerKind::Unknown) + { + return Err( + "target requires player role context but at least one player has unknown controller_kind" + .to_string(), + ); + } + Ok(state + .players + .iter() + .filter(|player| { + player.active && player.controller_kind == RuntimeCompanyControllerKind::Human + }) + .map(|player| player.player_id) + .collect()) + } + RuntimePlayerTarget::AiPlayers => { + if state + .players + .iter() + .any(|player| player.controller_kind == RuntimeCompanyControllerKind::Unknown) + { + return Err( + "target requires player role context but at least one player has unknown controller_kind" + .to_string(), + ); + } + Ok(state + .players + .iter() + .filter(|player| { + player.active && player.controller_kind == RuntimeCompanyControllerKind::Ai + }) + .map(|player| player.player_id) + .collect()) + } + RuntimePlayerTarget::SelectedPlayer => { + let selected_player_id = state + .selected_player_id + .ok_or_else(|| "target requires selected_player_id context".to_string())?; + if state + .players + .iter() + .any(|player| player.player_id == selected_player_id && player.active) + { + Ok(vec![selected_player_id]) + } else { + Err("target requires selected_player_id to reference an active player".to_string()) + } + } + RuntimePlayerTarget::ConditionTruePlayer => { + if condition_context.matching_player_ids.is_empty() { + Err("target requires player condition-evaluation context".to_string()) + } else { + Ok(condition_context.matching_player_ids.iter().copied().collect()) + } + } + } +} + +fn resolve_territory_target_ids( + state: &RuntimeState, + target: &RuntimeTerritoryTarget, +) -> Result, String> { + match target { + RuntimeTerritoryTarget::AllTerritories => { + Ok(state.territories.iter().map(|territory| territory.territory_id).collect()) + } + RuntimeTerritoryTarget::Ids { ids } => { + let known_ids = state + .territories + .iter() + .map(|territory| territory.territory_id) + .collect::>(); + for territory_id in ids { + if !known_ids.contains(territory_id) { + return Err(format!("territory target references unknown territory_id {territory_id}")); + } + } + Ok(ids.clone()) + } + } +} + fn company_metric_value(company: &crate::RuntimeCompany, metric: RuntimeCompanyMetric) -> i64 { match metric { RuntimeCompanyMetric::CurrentCash => company.current_cash, @@ -697,9 +841,14 @@ fn company_metric_value(company: &crate::RuntimeCompany, metric: RuntimeCompanyM } } -fn territory_metric_value(state: &RuntimeState, metric: RuntimeTerritoryMetric) -> i64 { +fn territory_metric_value( + state: &RuntimeState, + territory_ids: &[u32], + metric: RuntimeTerritoryMetric, +) -> i64 { state.territories .iter() + .filter(|territory| territory_ids.contains(&territory.territory_id)) .map(|territory| { track_piece_metric_value( territory.track_piece_counts, @@ -712,11 +861,12 @@ fn territory_metric_value(state: &RuntimeState, metric: RuntimeTerritoryMetric) fn company_territory_metric_value( state: &RuntimeState, company_id: u32, + territory_ids: &[u32], metric: RuntimeTrackMetric, ) -> i64 { state.company_territory_track_piece_counts .iter() - .filter(|entry| entry.company_id == company_id) + .filter(|entry| entry.company_id == company_id && territory_ids.contains(&entry.territory_id)) .map(|entry| track_piece_metric_value(entry.track_piece_counts, metric)) .sum() } @@ -805,6 +955,8 @@ mod tests { available_track_laying_capacity: None, }], selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index c48f864..9632693 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -29,6 +29,7 @@ pub struct RuntimeSummary { pub metadata_count: usize, pub company_count: usize, pub active_company_count: usize, + pub player_count: usize, pub territory_count: usize, pub company_territory_track_count: usize, pub packed_event_collection_present: bool, @@ -40,7 +41,11 @@ pub struct RuntimeSummary { pub packed_event_blocked_missing_company_context_count: usize, pub packed_event_blocked_missing_selection_context_count: usize, pub packed_event_blocked_missing_company_role_context_count: usize, + pub packed_event_blocked_missing_player_context_count: usize, + pub packed_event_blocked_missing_player_selection_context_count: usize, + pub packed_event_blocked_missing_player_role_context_count: usize, pub packed_event_blocked_missing_condition_context_count: usize, + pub packed_event_blocked_missing_player_condition_context_count: usize, 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, @@ -49,6 +54,7 @@ pub struct RuntimeSummary { 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_territory_policy_descriptor_count: usize, pub packed_event_blocked_structural_only_count: usize, pub event_runtime_record_count: usize, pub candidate_availability_count: usize, @@ -136,6 +142,7 @@ impl RuntimeSummary { .iter() .filter(|company| company.active) .count(), + player_count: state.players.len(), 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(), @@ -218,6 +225,48 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_missing_player_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_player_context") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_missing_player_selection_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_player_selection_context") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_missing_player_role_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_player_role_context") + }) + .count() + }) + .unwrap_or(0), packed_event_blocked_missing_condition_context_count: state .packed_event_collection .as_ref() @@ -232,6 +281,20 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_missing_player_condition_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_player_condition_context") + }) + .count() + }) + .unwrap_or(0), packed_event_blocked_company_condition_scope_disabled_count: state .packed_event_collection .as_ref() @@ -344,6 +407,20 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_territory_policy_descriptor_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_territory_policy_descriptor") + }) + .count() + }) + .unwrap_or(0), packed_event_blocked_structural_only_count: state .packed_event_collection .as_ref() @@ -425,6 +502,8 @@ mod tests { metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: Some(RuntimePackedEventCollectionSummary { @@ -650,6 +729,8 @@ mod tests { }, ], selected_company_id: None, + players: Vec::new(), + selected_player_id: None, territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, diff --git a/docs/README.md b/docs/README.md index f9b1962..7202070 100644 --- a/docs/README.md +++ b/docs/README.md @@ -81,14 +81,18 @@ The highest-value next passes are now: first company-scoped batch already parses, summarizes, and executes through the ordinary runtime path when overlay context resolves its symbolic company scope: descriptor `2` `Company Cash`, descriptor `13` `Deactivate Company`, and descriptor `16` `Company Track Pieces Buildable` +- descriptor `1` `Player Cash` now joins that executable real batch through the same ordinary + runtime path, backed by the minimal player runtime and overlay-import context - 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, 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 +- exact named-territory binding now executes too, while named-territory no-match cases remain the + explicit binding blocker frontier +- descriptor `3` `Territory - Allow All` remains the explicit parity-only descriptor frontier, and + mixed supported/unsupported real rows still 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 11f2f90..6b049e7 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -33,17 +33,20 @@ Implemented today: descriptor `13` = `Deactivate Company`, and descriptor `16` = `Company Track Pieces Buildable` - the first grounded condition-side unlock now exists for real packed rows: negative-sentinel `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 + targets during import - 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 + through overlay-backed runtime context +- exact named-territory binding now lowers candidate-name ordinary rows onto tracked territory + names, a minimal player runtime now carries selected-player and role context, and real descriptor + `1` = `Player Cash` now imports and executes through the ordinary runtime path +- descriptor `3` = `Territory - Allow All` now has an explicit parity-only frontier label instead + of hiding behind the generic unmapped bucket That means the next implementation work is breadth, not bootstrap. The recommended next slice is -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. +broader ordinary condition-id coverage beyond numeric thresholds, wider real grouped-descriptor +coverage beyond the current company/player cash batch, and later executable territory-policy +mutation once those semantics are grounded strongly enough to avoid guessing. ## Why This Boundary @@ -241,9 +244,10 @@ Current status: - 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 - 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 + snapshots, named-territory binding now executes on exact matches, and a minimal player runtime is + now present, so the remaining gap is broader ordinary condition-id coverage beyond numeric + thresholds plus wider real grouped-descriptor and territory-policy semantic coverage, not + first-pass captured-runtime plumbing ### Milestone 4: Domain Expansion @@ -398,10 +402,10 @@ Target behavior: - 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 +- keep named-territory ordinary rows on exact case-sensitive binding until captured evidence + justifies alias tables or fuzzier matching +- keep player-owned condition scope within the minimal event runtime model until later slices need + richer player metrics or profile/chairman ownership - 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 @@ -409,8 +413,8 @@ Public-model expectations for that slice: - 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 +- richer ordinary-condition metadata and later runtime ownership only where new condition domains + still remain blocked after the current named-territory and player-runtime unlocks - more selective real-row `decoded_conditions` and `decoded_actions` only where the condition/effect-to-runtime mapping is supported end to end @@ -418,7 +422,9 @@ Fixture work for that slice: - 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 +- preserve the named-territory no-match tracked overlay as the explicit binding blocker frontier +- preserve the territory-policy tracked sample as the explicit descriptor frontier until mutation + semantics are grounded strongly enough to move beyond parity-only - 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 diff --git a/fixtures/runtime/packed-event-ordinary-named-company-territory-overlay-fixture.json b/fixtures/runtime/packed-event-ordinary-named-company-territory-overlay-fixture.json new file mode 100644 index 0000000..c7a32bb --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-named-company-territory-overlay-fixture.json @@ -0,0 +1,71 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-ordinary-named-company-territory-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving named-territory company-territory ordinary rows bind exactly and execute through the normal runtime path." + }, + "state_import_path": "packed-event-ordinary-named-company-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, + "player_count": 2, + "territory_count": 2, + "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": 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": "company_territory_numeric_threshold", + "target": { + "kind": "selected_company" + }, + "territory": { + "kind": "ids", + "ids": [7] + }, + "metric": "total", + "comparator": "ge", + "value": 10 + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-ordinary-named-company-territory-overlay.json b/fixtures/runtime/packed-event-ordinary-named-company-territory-overlay.json new file mode 100644 index 0000000..ee9ec33 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-named-company-territory-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-ordinary-named-company-territory-overlay", + "source": { + "description": "Overlay import combining named-territory runtime context with the real company-territory threshold sample." + }, + "base_snapshot_path": "packed-event-territory-player-overlay-base-snapshot.json", + "save_slice_path": "packed-event-ordinary-named-company-territory-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-ordinary-named-company-territory-save-slice.json b/fixtures/runtime/packed-event-ordinary-named-company-territory-save-slice.json new file mode 100644 index 0000000..62eb711 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-named-company-territory-save-slice.json @@ -0,0 +1,144 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-ordinary-named-company-territory-save-slice", + "source": { + "description": "Tracked save-slice document with a real named-territory company-territory threshold row gating Company Cash.", + "original_save_filename": "captured-ordinary-named-company-territory.gms", + "original_save_sha256": "ordinary-named-company-territory-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves exact named-territory binding for company-territory thresholds" + ] + }, + "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": 46, + "live_record_count": 1, + "live_entry_ids": [46], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 46, + "payload_offset": 29240, + "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": "Appalachia", + "comparator": "ge", + "metric": "Company-Territory Track Pieces", + "semantic_family": "numeric_threshold", + "semantic_preview": "Test Company-Territory Track Pieces >= 10", + "requires_candidate_name_binding": true, + "notes": [ + "condition row carries candidate-name side string" + ] + } + ], + "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": 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": "company_territory_numeric_threshold", + "target": { + "kind": "condition_true_company" + }, + "territory": { + "kind": "all_territories" + }, + "metric": "total", + "comparator": "ge", + "value": 10 + } + ], + "decoded_actions": [ + { + "kind": "set_company_cash", + "target": { + "kind": "condition_true_company" + }, + "value": 444 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "named company-territory threshold lowers both company and territory scope at import time" + ] + } + ] + }, + "notes": [ + "real named company-territory threshold sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-named-territory-executable-overlay-fixture.json b/fixtures/runtime/packed-event-ordinary-named-territory-executable-overlay-fixture.json new file mode 100644 index 0000000..9b15ef5 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-named-territory-executable-overlay-fixture.json @@ -0,0 +1,75 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-ordinary-named-territory-executable-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving named-territory ordinary rows bind exactly and execute through the normal runtime path." + }, + "state_import_path": "packed-event-ordinary-named-territory-executable-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, + "player_count": 2, + "territory_count": 2, + "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_named_territory_binding_count": 0, + "event_runtime_record_count": 1, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1, + "total_company_cash": 1067 + }, + "expected_state_fragment": { + "companies": [ + { + "company_id": 1, + "current_cash": 777 + }, + { + "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", + "target": { + "kind": "ids", + "ids": [7] + }, + "metric": "track_pieces_total", + "comparator": "ge", + "value": 10 + } + ] + } + ] + }, + "event_runtime_records": [ + { + "record_id": 45, + "service_count": 1 + } + ] + } +} diff --git a/fixtures/runtime/packed-event-ordinary-named-territory-executable-overlay.json b/fixtures/runtime/packed-event-ordinary-named-territory-executable-overlay.json new file mode 100644 index 0000000..a14d597 --- /dev/null +++ b/fixtures/runtime/packed-event-ordinary-named-territory-executable-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-ordinary-named-territory-executable-overlay", + "source": { + "description": "Overlay import combining named-territory runtime context with the real named-territory threshold sample." + }, + "base_snapshot_path": "packed-event-territory-player-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 index e6e873e..1d28750 100644 --- a/fixtures/runtime/packed-event-ordinary-named-territory-save-slice.json +++ b/fixtures/runtime/packed-event-ordinary-named-territory-save-slice.json @@ -103,6 +103,9 @@ "decoded_conditions": [ { "kind": "territory_numeric_threshold", + "target": { + "kind": "all_territories" + }, "metric": "track_pieces_total", "comparator": "ge", "value": 10 diff --git a/fixtures/runtime/packed-event-parity-save-slice-fixture.json b/fixtures/runtime/packed-event-parity-save-slice-fixture.json index 9a707df..8af1edd 100644 --- a/fixtures/runtime/packed-event-parity-save-slice-fixture.json +++ b/fixtures/runtime/packed-event-parity-save-slice-fixture.json @@ -23,15 +23,15 @@ "packed_event_collection_present": true, "packed_event_record_count": 2, "packed_event_decoded_record_count": 1, - "packed_event_imported_runtime_record_count": 0, + "packed_event_imported_runtime_record_count": 1, "packed_event_parity_only_record_count": 1, "packed_event_unsupported_record_count": 1, "packed_event_blocked_missing_condition_context_count": 0, - "packed_event_blocked_territory_condition_scope_count": 1, + "packed_event_blocked_territory_condition_scope_count": 0, "packed_event_blocked_missing_compact_control_count": 0, "packed_event_blocked_unmapped_real_descriptor_count": 0, "packed_event_blocked_structural_only_count": 0, - "event_runtime_record_count": 0, + "event_runtime_record_count": 1, "total_company_cash": 0 }, "expected_state_fragment": { @@ -53,7 +53,7 @@ "payload_family": "real_packed_v1", "trigger_kind": 6, "one_shot": true, - "import_outcome": "blocked_territory_condition_scope", + "import_outcome": "imported", "compact_control": { "primary_selector_0x7f0": 99, "grouped_target_scope_ordinals_0x7fb": [0, 1, 2, 3] @@ -81,7 +81,7 @@ { "kind": "set_company_cash", "target": { - "kind": "condition_true_company" + "kind": "all_active" }, "value": 7 } diff --git a/fixtures/runtime/packed-event-parity-save-slice.json b/fixtures/runtime/packed-event-parity-save-slice.json index 0d71f32..04ed6a5 100644 --- a/fixtures/runtime/packed-event-parity-save-slice.json +++ b/fixtures/runtime/packed-event-parity-save-slice.json @@ -34,7 +34,7 @@ "live_record_count": 2, "live_entry_ids": [3, 5], "decoded_record_count": 1, - "imported_runtime_record_count": 0, + "imported_runtime_record_count": 1, "records": [ { "record_index": 0, diff --git a/fixtures/runtime/packed-event-player-cash-overlay-fixture.json b/fixtures/runtime/packed-event-player-cash-overlay-fixture.json new file mode 100644 index 0000000..158c11e --- /dev/null +++ b/fixtures/runtime/packed-event-player-cash-overlay-fixture.json @@ -0,0 +1,57 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-player-cash-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving descriptor 1 Player Cash imports and executes through the ordinary runtime path." + }, + "state_import_path": "packed-event-player-cash-overlay.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 6 + } + ], + "expected_summary": { + "calendar_projection_source": "base-snapshot-preserved", + "calendar_projection_is_placeholder": false, + "company_count": 3, + "player_count": 2, + "territory_count": 2, + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 1, + "event_runtime_record_count": 1, + "total_event_record_service_count": 1, + "total_trigger_dispatch_count": 1 + }, + "expected_state_fragment": { + "players": [ + { + "player_id": 1, + "current_cash": 888 + }, + { + "player_id": 2, + "current_cash": 250 + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_actions": [ + { + "kind": "set_player_cash", + "target": { + "kind": "selected_player" + }, + "value": 888 + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-player-cash-overlay.json b/fixtures/runtime/packed-event-player-cash-overlay.json new file mode 100644 index 0000000..724f314 --- /dev/null +++ b/fixtures/runtime/packed-event-player-cash-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-player-cash-overlay", + "source": { + "description": "Overlay import combining player runtime context with the real Player Cash descriptor sample." + }, + "base_snapshot_path": "packed-event-territory-player-overlay-base-snapshot.json", + "save_slice_path": "packed-event-player-cash-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-player-cash-save-slice.json b/fixtures/runtime/packed-event-player-cash-save-slice.json new file mode 100644 index 0000000..c503041 --- /dev/null +++ b/fixtures/runtime/packed-event-player-cash-save-slice.json @@ -0,0 +1,126 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-player-cash-save-slice", + "source": { + "description": "Tracked save-slice document with a real player-scoped Player Cash row.", + "original_save_filename": "captured-player-cash.gms", + "original_save_sha256": "player-cash-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves descriptor 1 import through the normal runtime 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": 47, + "live_record_count": 1, + "live_entry_ids": [47], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 47, + "payload_offset": 29280, + "payload_len": 140, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 6, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 6, + "primary_selector_0x7f0": 12, + "grouped_mode_0x7f4": 2, + "one_shot_header_0x7f5": 0, + "modifier_flag_0x7f9": 0, + "modifier_flag_0x7fa": 2, + "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": -1, + "subtype": 4, + "flag_bytes": [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72], + "candidate_name": null, + "notes": [ + "negative sentinel-style condition row id" + ] + } + ], + "negative_sentinel_scope": { + "company_test_scope": "disabled", + "player_test_scope": "selected_player_only", + "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": 1, + "descriptor_label": "Player Cash", + "target_mask_bits": 2, + "parameter_family": "player_finance_scalar", + "opcode": 8, + "raw_scalar_value": 888, + "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 Player Cash to 888 with aux [2, 3, 24, 36]", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [], + "decoded_actions": [ + { + "kind": "set_player_cash", + "target": { + "kind": "condition_true_player" + }, + "value": 888 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "player-side negative-sentinel scope lowers player cash at import time" + ] + } + ] + }, + "notes": [ + "real player cash descriptor sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-territory-player-overlay-base-snapshot.json b/fixtures/runtime/packed-event-territory-player-overlay-base-snapshot.json new file mode 100644 index 0000000..3c7702d --- /dev/null +++ b/fixtures/runtime/packed-event-territory-player-overlay-base-snapshot.json @@ -0,0 +1,158 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-territory-player-overlay-base-snapshot", + "source": { + "description": "Base runtime snapshot supplying named-territory and player-selection context for packed-event overlays." + }, + "state": { + "calendar": { + "year": 1840, + "month_slot": 1, + "phase_slot": 2, + "tick_slot": 3 + }, + "world_flags": { + "base.only": true + }, + "metadata": { + "base.note": "territory-and-player 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, + "players": [ + { + "player_id": 1, + "current_cash": 500, + "controller_kind": "human" + }, + { + "player_id": 2, + "current_cash": 250, + "controller_kind": "ai" + } + ], + "selected_player_id": 1, + "territories": [ + { + "territory_id": 7, + "name": "Appalachia", + "track_piece_counts": { + "total": 50, + "single": 10, + "double": 20, + "transition": 5, + "electric": 15, + "non_electric": 35 + } + }, + { + "territory_id": 8, + "name": "Great Plains", + "track_piece_counts": { + "total": 12, + "single": 4, + "double": 3, + "transition": 1, + "electric": 2, + "non_electric": 10 + } + } + ], + "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-territory-policy-save-slice-fixture.json b/fixtures/runtime/packed-event-territory-policy-save-slice-fixture.json new file mode 100644 index 0000000..ccb26df --- /dev/null +++ b/fixtures/runtime/packed-event-territory-policy-save-slice-fixture.json @@ -0,0 +1,39 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-territory-policy-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving descriptor 3 Territory - Allow All stays parity-only with an explicit blocker." + }, + "state_save_slice_path": "packed-event-territory-policy-save-slice.json", + "commands": [ + { + "kind": "step_count", + "steps": 1 + } + ], + "expected_summary": { + "calendar_projection_is_placeholder": true, + "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_territory_policy_descriptor_count": 1, + "event_runtime_record_count": 0 + }, + "expected_state_fragment": { + "packed_event_collection": { + "records": [ + { + "import_outcome": "blocked_territory_policy_descriptor", + "grouped_effect_rows": [ + { + "descriptor_label": "Territory - Allow All" + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-territory-policy-save-slice.json b/fixtures/runtime/packed-event-territory-policy-save-slice.json new file mode 100644 index 0000000..073b21d --- /dev/null +++ b/fixtures/runtime/packed-event-territory-policy-save-slice.json @@ -0,0 +1,102 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-territory-policy-save-slice", + "source": { + "description": "Tracked save-slice document with a real Territory - Allow All row that stays parity-only.", + "original_save_filename": "captured-territory-policy.gms", + "original_save_sha256": "territory-policy-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "keeps descriptor 3 explicit without guessing territory policy mutation semantics" + ] + }, + "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": 48, + "live_record_count": 1, + "live_entry_ids": [48], + "decoded_record_count": 1, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 48, + "payload_offset": 29320, + "payload_len": 132, + "decode_status": "parity_only", + "payload_family": "real_packed_v1", + "trigger_kind": 6, + "one_shot": false, + "compact_control": { + "mode_byte_0x7ef": 6, + "primary_selector_0x7f0": 12, + "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": [7, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 0, + "standalone_condition_rows": [], + "negative_sentinel_scope": null, + "grouped_effect_row_counts": [1, 0, 0, 0], + "grouped_effect_rows": [ + { + "group_index": 0, + "row_index": 0, + "descriptor_id": 3, + "descriptor_label": "Territory - Allow All", + "target_mask_bits": 5, + "parameter_family": "territory_access_toggle", + "opcode": 1, + "raw_scalar_value": 1, + "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": "bool_toggle", + "semantic_family": "bool_toggle", + "semantic_preview": "Set Territory - Allow All to TRUE", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [], + "decoded_actions": [], + "executable_import_ready": false, + "notes": [ + "decoded from grounded real 0x4e9a row framing", + "territory policy mutation remains parity-only in this slice" + ] + } + ] + }, + "notes": [ + "real territory policy descriptor sample" + ] + } +}