From e481274243af1f68100970491d43340a3c16a0c5 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Wed, 15 Apr 2026 20:20:25 -0700 Subject: [PATCH] Execute real packed event world and train descriptors --- README.md | 10 +- crates/rrt-fixtures/src/load.rs | 2 + crates/rrt-fixtures/src/schema.rs | 90 ++ crates/rrt-runtime/src/import.rs | 1309 ++++++++++++++++- crates/rrt-runtime/src/lib.rs | 2 +- crates/rrt-runtime/src/persistence.rs | 1 + crates/rrt-runtime/src/runtime.rs | 320 +++- crates/rrt-runtime/src/smp.rs | 148 +- crates/rrt-runtime/src/step.rs | 308 +++- crates/rrt-runtime/src/summary.rs | 86 +- docs/README.md | 3 + docs/runtime-rehost-plan.md | 14 +- ...nfiscate-all-false-save-slice-fixture.json | 32 + ...event-confiscate-all-false-save-slice.json | 101 ++ ...-event-confiscate-all-overlay-fixture.json | 81 + .../packed-event-confiscate-all-overlay.json | 9 + ...acked-event-confiscate-all-save-slice.json | 108 ++ ...event-economic-status-overlay-fixture.json | 48 + .../packed-event-economic-status-overlay.json | 9 + ...cked-event-economic-status-save-slice.json | 106 ++ ...ed-company-descriptor-overlay-fixture.json | 4 +- ...t-mixed-company-descriptor-save-slice.json | 18 +- ...-retire-train-company-overlay-fixture.json | 64 + ...ed-event-retire-train-company-overlay.json | 9 + ...event-retire-train-company-save-slice.json | 108 ++ ...rain-missing-scope-save-slice-fixture.json | 32 + ...retire-train-missing-scope-save-slice.json | 103 ++ ...etire-train-territory-overlay-fixture.json | 66 + ...-event-retire-train-territory-overlay.json | 9 + ...ent-retire-train-territory-save-slice.json | 112 ++ ...ent-world-train-overlay-base-snapshot.json | 81 + 31 files changed, 3237 insertions(+), 156 deletions(-) create mode 100644 fixtures/runtime/packed-event-confiscate-all-false-save-slice-fixture.json create mode 100644 fixtures/runtime/packed-event-confiscate-all-false-save-slice.json create mode 100644 fixtures/runtime/packed-event-confiscate-all-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-confiscate-all-overlay.json create mode 100644 fixtures/runtime/packed-event-confiscate-all-save-slice.json create mode 100644 fixtures/runtime/packed-event-economic-status-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-economic-status-overlay.json create mode 100644 fixtures/runtime/packed-event-economic-status-save-slice.json create mode 100644 fixtures/runtime/packed-event-retire-train-company-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-retire-train-company-overlay.json create mode 100644 fixtures/runtime/packed-event-retire-train-company-save-slice.json create mode 100644 fixtures/runtime/packed-event-retire-train-missing-scope-save-slice-fixture.json create mode 100644 fixtures/runtime/packed-event-retire-train-missing-scope-save-slice.json create mode 100644 fixtures/runtime/packed-event-retire-train-territory-overlay-fixture.json create mode 100644 fixtures/runtime/packed-event-retire-train-territory-overlay.json create mode 100644 fixtures/runtime/packed-event-retire-train-territory-save-slice.json create mode 100644 fixtures/runtime/packed-event-world-train-overlay-base-snapshot.json diff --git a/README.md b/README.md index 9faf4bc..042d99b 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,12 @@ parallel packed executor. The first grounded condition-side unlock now exists fo `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. +named-territory binding now executes, and the runtime now also carries the minimal event-owned +train roster and opaque economic-status lane needed for real descriptors `8` `Economic Status`, `9` +`Confiscate All`, and `15` `Retire Train` to execute through the same path. 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 01903d2..ca0bf19 100644 --- a/crates/rrt-fixtures/src/load.rs +++ b/crates/rrt-fixtures/src/load.rs @@ -176,6 +176,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -343,6 +344,7 @@ mod tests { selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, + trains: Vec::new(), 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 aa07ffa..d309637 100644 --- a/crates/rrt-fixtures/src/schema.rs +++ b/crates/rrt-fixtures/src/schema.rs @@ -54,6 +54,8 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub world_restore_ai_ignore_territories_at_startup_enabled: Option, #[serde(default)] + pub world_restore_economic_status_code: Option, + #[serde(default)] pub world_restore_absolute_counter_restore_kind: Option, #[serde(default)] pub world_restore_absolute_counter_adjustment_context: Option, @@ -66,6 +68,12 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub player_count: Option, #[serde(default)] + pub train_count: Option, + #[serde(default)] + pub active_train_count: Option, + #[serde(default)] + pub retired_train_count: Option, + #[serde(default)] pub territory_count: Option, #[serde(default)] pub company_territory_track_count: Option, @@ -116,6 +124,16 @@ pub struct ExpectedRuntimeSummary { #[serde(default)] pub packed_event_blocked_territory_policy_descriptor_count: Option, #[serde(default)] + pub packed_event_blocked_missing_train_context_count: Option, + #[serde(default)] + pub packed_event_blocked_missing_train_territory_context_count: Option, + #[serde(default)] + pub packed_event_blocked_confiscation_variant_count: Option, + #[serde(default)] + pub packed_event_blocked_retire_train_variant_count: Option, + #[serde(default)] + pub packed_event_blocked_retire_train_scope_count: Option, + #[serde(default)] pub packed_event_blocked_structural_only_count: Option, #[serde(default)] pub event_runtime_record_count: Option, @@ -321,6 +339,14 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(code) = self.world_restore_economic_status_code { + if actual.world_restore_economic_status_code != Some(code) { + mismatches.push(format!( + "world_restore_economic_status_code mismatch: expected {code}, got {:?}", + actual.world_restore_economic_status_code + )); + } + } if let Some(kind) = &self.world_restore_absolute_counter_restore_kind { if actual.world_restore_absolute_counter_restore_kind.as_ref() != Some(kind) { mismatches.push(format!( @@ -373,6 +399,30 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.train_count { + if actual.train_count != count { + mismatches.push(format!( + "train_count mismatch: expected {count}, got {}", + actual.train_count + )); + } + } + if let Some(count) = self.active_train_count { + if actual.active_train_count != count { + mismatches.push(format!( + "active_train_count mismatch: expected {count}, got {}", + actual.active_train_count + )); + } + } + if let Some(count) = self.retired_train_count { + if actual.retired_train_count != count { + mismatches.push(format!( + "retired_train_count mismatch: expected {count}, got {}", + actual.retired_train_count + )); + } + } if let Some(count) = self.territory_count { if actual.territory_count != count { mismatches.push(format!( @@ -573,6 +623,46 @@ impl ExpectedRuntimeSummary { )); } } + if let Some(count) = self.packed_event_blocked_missing_train_context_count { + if actual.packed_event_blocked_missing_train_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_train_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_train_context_count + )); + } + } + if let Some(count) = self.packed_event_blocked_missing_train_territory_context_count { + if actual.packed_event_blocked_missing_train_territory_context_count != count { + mismatches.push(format!( + "packed_event_blocked_missing_train_territory_context_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_missing_train_territory_context_count + )); + } + } + if let Some(count) = self.packed_event_blocked_confiscation_variant_count { + if actual.packed_event_blocked_confiscation_variant_count != count { + mismatches.push(format!( + "packed_event_blocked_confiscation_variant_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_confiscation_variant_count + )); + } + } + if let Some(count) = self.packed_event_blocked_retire_train_variant_count { + if actual.packed_event_blocked_retire_train_variant_count != count { + mismatches.push(format!( + "packed_event_blocked_retire_train_variant_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_retire_train_variant_count + )); + } + } + if let Some(count) = self.packed_event_blocked_retire_train_scope_count { + if actual.packed_event_blocked_retire_train_scope_count != count { + mismatches.push(format!( + "packed_event_blocked_retire_train_scope_count mismatch: expected {count}, got {}", + actual.packed_event_blocked_retire_train_scope_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 46570ea..9cbdfd6 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -13,8 +13,9 @@ use crate::{ RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeTerritoryTarget, RuntimeWorldRestoreState, - SmpLoadedPackedEventConditionRowSummary, SmpLoadedPackedEventNegativeSentinelScopeSummary, - SmpLoadedPackedEventRecordSummary, SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice, + SmpLoadedPackedEventConditionRowSummary, SmpLoadedPackedEventGroupedEffectRowSummary, + SmpLoadedPackedEventNegativeSentinelScopeSummary, SmpLoadedPackedEventRecordSummary, + SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice, }; pub const STATE_DUMP_FORMAT_VERSION: u32 = 1; @@ -110,8 +111,11 @@ struct ImportRuntimeContext { known_player_ids: BTreeSet, selected_player_id: Option, has_complete_player_controller_context: bool, + known_territory_ids: BTreeSet, has_territory_context: bool, territory_name_to_id: BTreeMap, + has_train_context: bool, + has_train_territory_context: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -128,6 +132,8 @@ enum ImportBlocker { MissingTerritoryContext, NamedTerritoryBinding, UnmappedOrdinaryCondition, + MissingTrainContext, + MissingTrainTerritoryContext, } impl ImportRuntimeContext { @@ -139,8 +145,11 @@ impl ImportRuntimeContext { known_player_ids: BTreeSet::new(), selected_player_id: None, has_complete_player_controller_context: false, + known_territory_ids: BTreeSet::new(), has_territory_context: false, territory_name_to_id: BTreeMap::new(), + has_train_context: false, + has_train_territory_context: false, } } @@ -156,12 +165,22 @@ impl ImportRuntimeContext { && state.companies.iter().all(|company| { company.controller_kind != RuntimeCompanyControllerKind::Unknown }), - known_player_ids: state.players.iter().map(|player| player.player_id).collect(), + 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 - }), + && state + .players + .iter() + .all(|player| player.controller_kind != RuntimeCompanyControllerKind::Unknown), + known_territory_ids: state + .territories + .iter() + .map(|territory| territory.territory_id) + .collect(), has_territory_context: !state.territories.is_empty(), territory_name_to_id: state .territories @@ -173,6 +192,11 @@ impl ImportRuntimeContext { .map(|name| (name.clone(), territory.territory_id)) }) .collect(), + has_train_context: !state.trains.is_empty(), + has_train_territory_context: state + .trains + .iter() + .any(|train| train.territory_id.is_some()), } } } @@ -206,6 +230,7 @@ pub fn project_save_slice_to_runtime_state_import( selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: projection.packed_event_collection, @@ -259,6 +284,7 @@ pub fn project_save_slice_overlay_to_runtime_state_import( selected_company_id: base_state.selected_company_id, players: base_state.players.clone(), selected_player_id: base_state.selected_player_id, + trains: base_state.trains.clone(), territories: base_state.territories.clone(), company_territory_track_piece_counts: base_state .company_territory_track_piece_counts @@ -503,6 +529,7 @@ fn project_save_slice_components( disable_train_crashes_enabled: special_condition_enabled(32), disable_train_crashes_and_breakdowns_enabled: special_condition_enabled(33), ai_ignore_territories_at_startup_enabled: special_condition_enabled(34), + economic_status_code: None, absolute_counter_restore_kind: Some( "mode-adjusted-selected-year-lane".to_string(), ), @@ -976,14 +1003,14 @@ fn lowered_condition_true_player_target( .as_ref() .ok_or(ImportBlocker::MissingPlayerConditionContext)?; match scope.player_test_scope { - RuntimePlayerConditionTestScope::Disabled => Err(ImportBlocker::MissingPlayerConditionContext), + RuntimePlayerConditionTestScope::Disabled => { + Err(ImportBlocker::MissingPlayerConditionContext) + } RuntimePlayerConditionTestScope::AllPlayers => Ok(Some(RuntimePlayerTarget::AllActive)), RuntimePlayerConditionTestScope::SelectedPlayerOnly => { Ok(Some(RuntimePlayerTarget::SelectedPlayer)) } - RuntimePlayerConditionTestScope::AiPlayersOnly => { - Ok(Some(RuntimePlayerTarget::AiPlayers)) - } + RuntimePlayerConditionTestScope::AiPlayersOnly => Ok(Some(RuntimePlayerTarget::AiPlayers)), RuntimePlayerConditionTestScope::HumanPlayersOnly => { Ok(Some(RuntimePlayerTarget::HumanPlayers)) } @@ -1000,6 +1027,9 @@ fn lower_condition_targets_in_effect( key: key.clone(), value: *value, }, + RuntimeEffect::SetEconomicStatusCode { value } => { + RuntimeEffect::SetEconomicStatusCode { value: *value } + } RuntimeEffect::SetCompanyCash { target, value } => RuntimeEffect::SetCompanyCash { target: lower_condition_true_company_target_in_company_target( target, @@ -1014,6 +1044,14 @@ fn lower_condition_targets_in_effect( )?, value: *value, }, + RuntimeEffect::ConfiscateCompanyAssets { target } => { + RuntimeEffect::ConfiscateCompanyAssets { + target: lower_condition_true_company_target_in_company_target( + target, + lowered_company_target, + )?, + } + } RuntimeEffect::DeactivateCompany { target } => RuntimeEffect::DeactivateCompany { target: lower_condition_true_company_target_in_company_target( target, @@ -1029,6 +1067,23 @@ fn lower_condition_targets_in_effect( value: *value, } } + RuntimeEffect::RetireTrains { + company_target, + territory_target, + locomotive_name, + } => RuntimeEffect::RetireTrains { + company_target: company_target + .as_ref() + .map(|target| { + lower_condition_true_company_target_in_company_target( + target, + lowered_company_target, + ) + }) + .transpose()?, + territory_target: territory_target.clone(), + locomotive_name: locomotive_name.clone(), + }, RuntimeEffect::AdjustCompanyCash { target, delta } => RuntimeEffect::AdjustCompanyCash { target: lower_condition_true_company_target_in_company_target( target, @@ -1189,12 +1244,21 @@ fn lower_territory_target_in_condition( } 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) + 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) + record + .decoded_actions + .iter() + .any(runtime_effect_uses_condition_true_player) } fn condition_uses_condition_true_company(condition: &RuntimeCondition) -> bool { @@ -1237,6 +1301,9 @@ fn smp_runtime_effect_to_runtime_effect( key: key.clone(), value: *value, }), + RuntimeEffect::SetEconomicStatusCode { value } => { + Ok(RuntimeEffect::SetEconomicStatusCode { value: *value }) + } RuntimeEffect::SetCompanyCash { target, value } => { if company_target_allowed_for_import( target, @@ -1265,6 +1332,22 @@ fn smp_runtime_effect_to_runtime_effect( Err(player_target_import_error_message(target, company_context)) } } + RuntimeEffect::ConfiscateCompanyAssets { target } => { + if company_target_allowed_for_import( + target, + company_context, + allow_condition_true_company, + ) && company_context.has_train_context + { + Ok(RuntimeEffect::ConfiscateCompanyAssets { + target: target.clone(), + }) + } else if !company_context.has_train_context { + Err("packed effect requires runtime train context".to_string()) + } else { + Err(company_target_import_error_message(target, company_context)) + } + } RuntimeEffect::DeactivateCompany { target } => { if company_target_allowed_for_import( target, @@ -1292,6 +1375,61 @@ fn smp_runtime_effect_to_runtime_effect( Err(company_target_import_error_message(target, company_context)) } } + RuntimeEffect::RetireTrains { + company_target, + territory_target, + locomotive_name, + } => { + if !company_context.has_train_context { + Err("packed effect requires runtime train context".to_string()) + } else if territory_target.is_some() && !company_context.has_train_territory_context { + Err("packed train effect requires runtime train territory context".to_string()) + } else if let Some(company_target) = company_target { + if !company_target_allowed_for_import( + company_target, + company_context, + allow_condition_true_company, + ) { + Err(company_target_import_error_message( + company_target, + company_context, + )) + } else if let Some(territory_target) = territory_target { + if territory_target_import_blocker(territory_target, company_context).is_some() + { + Err("packed condition requires territory runtime context".to_string()) + } else { + Ok(RuntimeEffect::RetireTrains { + company_target: Some(company_target.clone()), + territory_target: Some(territory_target.clone()), + locomotive_name: locomotive_name.clone(), + }) + } + } else { + Ok(RuntimeEffect::RetireTrains { + company_target: Some(company_target.clone()), + territory_target: None, + locomotive_name: locomotive_name.clone(), + }) + } + } else if let Some(territory_target) = territory_target { + if territory_target_import_blocker(territory_target, company_context).is_some() { + Err("packed condition requires territory runtime context".to_string()) + } else { + Ok(RuntimeEffect::RetireTrains { + company_target: None, + territory_target: Some(territory_target.clone()), + locomotive_name: locomotive_name.clone(), + }) + } + } else { + Ok(RuntimeEffect::RetireTrains { + company_target: None, + territory_target: None, + locomotive_name: locomotive_name.clone(), + }) + } + } RuntimeEffect::AdjustCompanyCash { target, delta } => { if company_target_allowed_for_import( target, @@ -1487,6 +1625,12 @@ fn company_target_import_error_message( Some(ImportBlocker::UnmappedOrdinaryCondition) => { "packed ordinary condition is not yet mapped".to_string() } + Some(ImportBlocker::MissingTrainContext) => { + "packed effect requires runtime train context".to_string() + } + Some(ImportBlocker::MissingTrainTerritoryContext) => { + "packed train effect requires runtime train territory context".to_string() + } Some(ImportBlocker::MissingPlayerContext) | Some(ImportBlocker::MissingPlayerSelectionContext) | Some(ImportBlocker::MissingPlayerRoleContext) @@ -1534,7 +1678,9 @@ fn player_target_import_blocker( Some(ImportBlocker::MissingPlayerSelectionContext) } } - RuntimePlayerTarget::ConditionTruePlayer => Some(ImportBlocker::MissingPlayerConditionContext), + RuntimePlayerTarget::ConditionTruePlayer => { + Some(ImportBlocker::MissingPlayerConditionContext) + } } } @@ -1582,6 +1728,27 @@ fn determine_packed_event_import_outcome( { return "blocked_territory_policy_descriptor".to_string(); } + if record + .grouped_effect_rows + .iter() + .any(real_grouped_row_is_unsupported_confiscation_variant) + { + return "blocked_confiscation_variant".to_string(); + } + if record + .grouped_effect_rows + .iter() + .any(real_grouped_row_is_unsupported_retire_train_scope) + { + return "blocked_retire_train_scope".to_string(); + } + if record + .grouped_effect_rows + .iter() + .any(real_grouped_row_is_unsupported_retire_train_variant) + { + return "blocked_retire_train_variant".to_string(); + } return if record .standalone_condition_rows .iter() @@ -1612,14 +1779,10 @@ fn packed_record_company_target_import_blocker( record: &SmpLoadedPackedEventRecordSummary, company_context: &ImportRuntimeContext, ) -> Option { - if record - .decoded_actions - .iter() - .any(|effect| { - runtime_effect_uses_condition_true_company(effect) - || runtime_effect_uses_condition_true_player(effect) - }) - && record.negative_sentinel_scope.is_none() + if record.decoded_actions.iter().any(|effect| { + runtime_effect_uses_condition_true_company(effect) + || runtime_effect_uses_condition_true_player(effect) + }) && record.negative_sentinel_scope.is_none() { return Some(ImportBlocker::MissingConditionContext); } @@ -1671,8 +1834,7 @@ fn territory_target_import_blocker( RuntimeTerritoryTarget::Ids { ids } => { if ids.is_empty() { Some(ImportBlocker::NamedTerritoryBinding) - } else if !territory_ids_match_known_names(ids, company_context) - { + } else if !territory_ids_match_known_context(ids, company_context) { Some(ImportBlocker::NamedTerritoryBinding) } else { None @@ -1687,48 +1849,68 @@ fn company_target_import_outcome(blocker: ImportBlocker) -> &'static str { 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::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::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", + ImportBlocker::MissingTrainContext => "blocked_missing_train_context", + ImportBlocker::MissingTrainTerritoryContext => "blocked_missing_train_territory_context", } } -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 territory_ids_match_known_context(ids: &[u32], company_context: &ImportRuntimeContext) -> bool { + ids.iter() + .all(|territory_id| company_context.known_territory_ids.contains(territory_id)) } + +fn real_grouped_row_is_unsupported_confiscation_variant( + row: &SmpLoadedPackedEventGroupedEffectRowSummary, +) -> bool { + row.descriptor_id == 9 && !(row.row_shape == "bool_toggle" && row.raw_scalar_value != 0) +} + +fn real_grouped_row_is_unsupported_retire_train_variant( + row: &SmpLoadedPackedEventGroupedEffectRowSummary, +) -> bool { + row.descriptor_id == 15 && !(row.row_shape == "bool_toggle" && row.raw_scalar_value != 0) +} + +fn real_grouped_row_is_unsupported_retire_train_scope( + row: &SmpLoadedPackedEventGroupedEffectRowSummary, +) -> bool { + row.descriptor_id == 15 + && row.row_shape == "bool_toggle" + && row.raw_scalar_value != 0 + && row + .notes + .iter() + .any(|note| note == "retire train row is missing company and territory scope") +} + fn runtime_effect_uses_condition_true_company(effect: &RuntimeEffect) -> bool { match effect { RuntimeEffect::SetCompanyCash { target, .. } + | RuntimeEffect::ConfiscateCompanyAssets { target } | RuntimeEffect::DeactivateCompany { target } | RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. } | RuntimeEffect::AdjustCompanyCash { target, .. } | RuntimeEffect::AdjustCompanyDebt { target, .. } => { matches!(target, RuntimeCompanyTarget::ConditionTrueCompany) } + RuntimeEffect::RetireTrains { company_target, .. } => matches!( + company_target, + Some(RuntimeCompanyTarget::ConditionTrueCompany) + ), RuntimeEffect::AppendEventRecord { record } => record .effects .iter() .any(runtime_effect_uses_condition_true_company), RuntimeEffect::SetWorldFlag { .. } + | RuntimeEffect::SetEconomicStatusCode { .. } | RuntimeEffect::SetPlayerCash { .. } | RuntimeEffect::SetCandidateAvailability { .. } | RuntimeEffect::SetSpecialCondition { .. } @@ -1757,19 +1939,47 @@ fn runtime_effect_company_target_import_blocker( ) -> Option { match effect { RuntimeEffect::SetCompanyCash { target, .. } + | RuntimeEffect::ConfiscateCompanyAssets { target } | RuntimeEffect::DeactivateCompany { target } | RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. } | RuntimeEffect::AdjustCompanyCash { target, .. } | RuntimeEffect::AdjustCompanyDebt { target, .. } => { - company_target_import_blocker(target, company_context) + if matches!(effect, RuntimeEffect::ConfiscateCompanyAssets { .. }) + && !company_context.has_train_context + { + Some(ImportBlocker::MissingTrainContext) + } else { + company_target_import_blocker(target, company_context) + } } RuntimeEffect::SetPlayerCash { target, .. } => { player_target_import_blocker(target, company_context) } + RuntimeEffect::RetireTrains { + company_target, + territory_target, + .. + } => { + if !company_context.has_train_context { + return Some(ImportBlocker::MissingTrainContext); + } + if territory_target.is_some() && !company_context.has_train_territory_context { + return Some(ImportBlocker::MissingTrainTerritoryContext); + } + company_target + .as_ref() + .and_then(|target| company_target_import_blocker(target, company_context)) + .or_else(|| { + territory_target + .as_ref() + .and_then(|target| territory_target_import_blocker(target, company_context)) + }) + } RuntimeEffect::AppendEventRecord { record } => record.effects.iter().find_map(|nested| { runtime_effect_company_target_import_blocker(nested, company_context) }), RuntimeEffect::SetWorldFlag { .. } + | RuntimeEffect::SetEconomicStatusCode { .. } | RuntimeEffect::SetCandidateAvailability { .. } | RuntimeEffect::SetSpecialCondition { .. } | RuntimeEffect::ActivateEventRecord { .. } @@ -2084,7 +2294,7 @@ fn resolve_document_path(base_dir: &Path, path: &str) -> PathBuf { #[cfg(test)] mod tests { use super::*; - use crate::{RuntimeTrackPieceCounts, StepCommand, execute_step_command}; + use crate::{RuntimeTrackPieceCounts, RuntimeTrain, StepCommand, execute_step_command}; fn state() -> RuntimeState { RuntimeState { @@ -2102,6 +2312,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -2306,16 +2517,16 @@ mod tests { } } - fn unsupported_real_grouped_row() -> crate::SmpLoadedPackedEventGroupedEffectRowSummary { + fn real_economic_status_row(value: i32) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary { crate::SmpLoadedPackedEventGroupedEffectRowSummary { - group_index: 1, + group_index: 0, row_index: 0, descriptor_id: 8, descriptor_label: Some("Economic Status".to_string()), target_mask_bits: Some(0x08), parameter_family: Some("whole_game_state_enum".to_string()), opcode: 3, - raw_scalar_value: 2, + raw_scalar_value: value, value_byte_0x09: 0, value_dword_0x0d: 0, value_byte_0x11: 0, @@ -2324,7 +2535,91 @@ mod tests { value_word_0x16: 0, row_shape: "scalar_assignment".to_string(), semantic_family: Some("scalar_assignment".to_string()), - semantic_preview: Some("Set Economic Status to 2".to_string()), + semantic_preview: Some(format!("Set Economic Status to {value}")), + locomotive_name: None, + notes: vec![], + } + } + + fn real_confiscate_all_row( + enabled: bool, + ) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary { + crate::SmpLoadedPackedEventGroupedEffectRowSummary { + group_index: 0, + row_index: 0, + descriptor_id: 9, + descriptor_label: Some("Confiscate All".to_string()), + target_mask_bits: Some(0x01), + parameter_family: Some("company_confiscation_variant".to_string()), + opcode: 1, + raw_scalar_value: if enabled { 1 } else { 0 }, + 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".to_string(), + semantic_family: Some("bool_toggle".to_string()), + semantic_preview: Some(format!( + "Set Confiscate All to {}", + if enabled { "TRUE" } else { "FALSE" } + )), + locomotive_name: None, + notes: vec![], + } + } + + fn real_retire_train_row( + enabled: bool, + locomotive_name: Option<&str>, + notes: Vec, + ) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary { + crate::SmpLoadedPackedEventGroupedEffectRowSummary { + group_index: 0, + row_index: 0, + descriptor_id: 15, + descriptor_label: Some("Retire Train".to_string()), + target_mask_bits: Some(0x0d), + parameter_family: Some("company_or_territory_asset_toggle".to_string()), + opcode: 1, + raw_scalar_value: if enabled { 1 } else { 0 }, + 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".to_string(), + semantic_family: Some("bool_toggle".to_string()), + semantic_preview: Some(format!( + "Set Retire Train to {}", + if enabled { "TRUE" } else { "FALSE" } + )), + locomotive_name: locomotive_name.map(ToString::to_string), + notes, + } + } + + fn unsupported_real_grouped_row() -> crate::SmpLoadedPackedEventGroupedEffectRowSummary { + crate::SmpLoadedPackedEventGroupedEffectRowSummary { + group_index: 1, + row_index: 0, + descriptor_id: 9, + descriptor_label: Some("Confiscate All".to_string()), + target_mask_bits: Some(0x01), + parameter_family: Some("company_confiscation_variant".to_string()), + opcode: 1, + raw_scalar_value: 0, + 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".to_string(), + semantic_family: Some("bool_toggle".to_string()), + semantic_preview: Some("Set Confiscate All to FALSE".to_string()), locomotive_name: None, notes: vec![], } @@ -3686,19 +3981,24 @@ mod tests { compact_control: Some(real_compact_control()), text_bands: packed_text_bands(), standalone_condition_row_count: 1, - 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(), - }], + 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(), @@ -3836,6 +4136,7 @@ mod tests { selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -4251,6 +4552,886 @@ mod tests { ); } + #[test] + fn overlays_real_economic_status_descriptor_into_executable_runtime_record() { + let base_state = state(); + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 18, + live_record_count: 1, + live_entry_ids: vec![18], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 18, + payload_offset: Some(0x7202), + payload_len: Some(120), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(7), + active: None, + marks_collection_dirty: None, + one_shot: Some(false), + compact_control: Some(real_compact_control()), + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_economic_status_row(2)], + decoded_conditions: Vec::new(), + decoded_actions: vec![RuntimeEffect::SetEconomicStatusCode { value: 2 }], + executable_import_ready: true, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "real-economic-status-overlay", + None, + ) + .expect("overlay import should project"); + + execute_step_command( + &mut import.state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("real economic-status descriptor should execute"); + + assert_eq!(import.state.world_restore.economic_status_code, Some(2)); + } + + #[test] + fn overlays_real_confiscate_all_descriptor_into_executable_runtime_record() { + let base_state = RuntimeState { + companies: vec![ + crate::RuntimeCompany { + company_id: 42, + controller_kind: RuntimeCompanyControllerKind::Human, + current_cash: 500, + debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }, + crate::RuntimeCompany { + company_id: 7, + controller_kind: RuntimeCompanyControllerKind::Ai, + current_cash: 90, + debt: 10, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }, + ], + selected_company_id: Some(42), + trains: vec![ + RuntimeTrain { + train_id: 1, + owner_company_id: 42, + territory_id: None, + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 2, + owner_company_id: 7, + territory_id: None, + locomotive_name: Some("Orca".to_string()), + active: true, + retired: false, + }, + ], + ..state() + }; + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 19, + live_record_count: 1, + live_entry_ids: vec![19], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 19, + payload_offset: Some(0x7202), + payload_len: Some(120), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(7), + active: None, + marks_collection_dirty: None, + one_shot: Some(false), + compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary { + mode_byte_0x7ef: 7, + primary_selector_0x7f0: 0x63, + grouped_mode_0x7f4: 2, + one_shot_header_0x7f5: 0, + modifier_flag_0x7f9: 1, + modifier_flag_0x7fa: 0, + grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1], + grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0], + summary_toggle_0x800: 1, + grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1], + }), + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_confiscate_all_row(true)], + decoded_conditions: Vec::new(), + decoded_actions: vec![RuntimeEffect::ConfiscateCompanyAssets { + target: RuntimeCompanyTarget::SelectedCompany, + }], + executable_import_ready: true, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "real-confiscate-all-overlay", + None, + ) + .expect("overlay import should project"); + + execute_step_command( + &mut import.state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("real confiscate-all descriptor should execute"); + + assert_eq!(import.state.companies[0].current_cash, 0); + assert_eq!(import.state.companies[0].debt, 0); + assert!(!import.state.companies[0].active); + assert_eq!(import.state.selected_company_id, None); + assert!(!import.state.trains[0].active); + assert!(import.state.trains[0].retired); + assert!(import.state.trains[1].active); + assert!(!import.state.trains[1].retired); + } + + #[test] + fn keeps_real_confiscate_all_false_row_parity_only() { + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 20, + live_record_count: 1, + live_entry_ids: vec![20], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 20, + payload_offset: Some(0x7202), + payload_len: Some(120), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(7), + active: None, + marks_collection_dirty: None, + one_shot: Some(false), + compact_control: Some(real_compact_control()), + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_confiscate_all_row(false)], + decoded_conditions: Vec::new(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_to_runtime_state_import( + &save_slice, + "real-confiscate-all-false", + None, + ) + .expect("save slice should project"); + + assert!(import.state.event_runtime_records.is_empty()); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .and_then(|summary| summary.records[0].import_outcome.as_deref()), + Some("blocked_confiscation_variant") + ); + } + + #[test] + fn blocks_confiscate_all_without_train_context() { + let base_state = RuntimeState { + companies: vec![crate::RuntimeCompany { + company_id: 42, + controller_kind: RuntimeCompanyControllerKind::Human, + current_cash: 500, + debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }], + selected_company_id: Some(42), + ..state() + }; + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 24, + live_record_count: 1, + live_entry_ids: vec![24], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 24, + payload_offset: Some(0x7202), + payload_len: Some(120), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(7), + active: None, + marks_collection_dirty: None, + one_shot: Some(false), + compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary { + mode_byte_0x7ef: 7, + primary_selector_0x7f0: 0x63, + grouped_mode_0x7f4: 2, + one_shot_header_0x7f5: 0, + modifier_flag_0x7f9: 1, + modifier_flag_0x7fa: 0, + grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1], + grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0], + summary_toggle_0x800: 1, + grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1], + }), + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_confiscate_all_row(true)], + decoded_conditions: Vec::new(), + decoded_actions: vec![RuntimeEffect::ConfiscateCompanyAssets { + target: RuntimeCompanyTarget::SelectedCompany, + }], + executable_import_ready: true, + notes: vec![], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "real-confiscate-all-missing-trains", + None, + ) + .expect("overlay import should project"); + + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .and_then(|summary| summary.records[0].import_outcome.as_deref()), + Some("blocked_missing_train_context") + ); + assert!(import.state.event_runtime_records.is_empty()); + } + + #[test] + fn overlays_real_retire_train_descriptor_by_company_scope() { + let base_state = RuntimeState { + companies: vec![ + crate::RuntimeCompany { + company_id: 42, + controller_kind: RuntimeCompanyControllerKind::Human, + current_cash: 500, + debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }, + crate::RuntimeCompany { + company_id: 7, + controller_kind: RuntimeCompanyControllerKind::Ai, + current_cash: 90, + debt: 10, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }, + ], + selected_company_id: Some(42), + trains: vec![ + RuntimeTrain { + train_id: 1, + owner_company_id: 42, + territory_id: Some(7), + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 2, + owner_company_id: 42, + territory_id: Some(8), + locomotive_name: Some("Orca".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 3, + owner_company_id: 7, + territory_id: Some(7), + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + ], + territories: vec![ + crate::RuntimeTerritory { + territory_id: 7, + name: Some("Appalachia".to_string()), + track_piece_counts: RuntimeTrackPieceCounts::default(), + }, + crate::RuntimeTerritory { + territory_id: 8, + name: Some("Great Plains".to_string()), + track_piece_counts: RuntimeTrackPieceCounts::default(), + }, + ], + ..state() + }; + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 21, + live_record_count: 1, + live_entry_ids: vec![21], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 21, + payload_offset: Some(0x7202), + payload_len: Some(120), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(7), + active: None, + marks_collection_dirty: None, + one_shot: Some(false), + compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary { + mode_byte_0x7ef: 7, + primary_selector_0x7f0: 0x63, + grouped_mode_0x7f4: 2, + one_shot_header_0x7f5: 0, + modifier_flag_0x7f9: 1, + modifier_flag_0x7fa: 0, + grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1], + grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0], + summary_toggle_0x800: 1, + grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1], + }), + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_retire_train_row(true, None, vec![])], + decoded_conditions: Vec::new(), + decoded_actions: vec![RuntimeEffect::RetireTrains { + company_target: Some(RuntimeCompanyTarget::SelectedCompany), + territory_target: None, + locomotive_name: None, + }], + executable_import_ready: true, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "real-retire-train-company-overlay", + None, + ) + .expect("overlay import should project"); + + execute_step_command( + &mut import.state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("real retire-train descriptor should execute"); + + assert!(import.state.trains[0].retired); + assert!(import.state.trains[1].retired); + assert!(!import.state.trains[2].retired); + } + + #[test] + fn overlays_real_retire_train_descriptor_by_territory_and_locomotive_scope() { + let base_state = RuntimeState { + companies: vec![crate::RuntimeCompany { + company_id: 42, + controller_kind: RuntimeCompanyControllerKind::Human, + current_cash: 500, + debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }], + selected_company_id: Some(42), + trains: vec![ + RuntimeTrain { + train_id: 1, + owner_company_id: 42, + territory_id: Some(7), + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 2, + owner_company_id: 42, + territory_id: Some(7), + locomotive_name: Some("Orca".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 3, + owner_company_id: 42, + territory_id: Some(8), + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + ], + territories: vec![ + crate::RuntimeTerritory { + territory_id: 7, + name: Some("Appalachia".to_string()), + track_piece_counts: RuntimeTrackPieceCounts::default(), + }, + crate::RuntimeTerritory { + territory_id: 8, + name: Some("Great Plains".to_string()), + track_piece_counts: RuntimeTrackPieceCounts::default(), + }, + ], + ..state() + }; + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 22, + live_record_count: 1, + live_entry_ids: vec![22], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 22, + payload_offset: Some(0x7202), + payload_len: Some(120), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(7), + active: None, + marks_collection_dirty: None, + one_shot: Some(false), + compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary { + mode_byte_0x7ef: 7, + primary_selector_0x7f0: 0x63, + grouped_mode_0x7f4: 2, + one_shot_header_0x7f5: 0, + modifier_flag_0x7f9: 1, + modifier_flag_0x7fa: 0, + grouped_target_scope_ordinals_0x7fb: vec![8, 1, 1, 1], + grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0], + summary_toggle_0x800: 1, + grouped_territory_selectors_0x80f: vec![7, -1, -1, -1], + }), + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_retire_train_row(true, Some("Mikado"), vec![])], + decoded_conditions: Vec::new(), + decoded_actions: vec![RuntimeEffect::RetireTrains { + company_target: None, + territory_target: Some(RuntimeTerritoryTarget::Ids { ids: vec![7] }), + locomotive_name: Some("Mikado".to_string()), + }], + executable_import_ready: true, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }], + }), + notes: vec![], + }; + + let mut import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "real-retire-train-territory-overlay", + None, + ) + .expect("overlay import should project"); + + execute_step_command( + &mut import.state, + &StepCommand::ServiceTriggerKind { trigger_kind: 7 }, + ) + .expect("territory-scoped retire-train descriptor should execute"); + + assert!(import.state.trains[0].retired); + assert!(!import.state.trains[1].retired); + assert!(!import.state.trains[2].retired); + } + + #[test] + fn keeps_real_retire_train_missing_scope_parity_only() { + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 23, + live_record_count: 1, + live_entry_ids: vec![23], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 23, + payload_offset: Some(0x7202), + payload_len: Some(120), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(7), + active: None, + marks_collection_dirty: None, + one_shot: Some(false), + compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary { + mode_byte_0x7ef: 7, + primary_selector_0x7f0: 0x63, + grouped_mode_0x7f4: 2, + one_shot_header_0x7f5: 0, + modifier_flag_0x7f9: 1, + modifier_flag_0x7fa: 0, + grouped_target_scope_ordinals_0x7fb: vec![8, 1, 1, 1], + grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0], + summary_toggle_0x800: 1, + grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1], + }), + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_retire_train_row( + true, + Some("Mikado"), + vec!["retire train row is missing company and territory scope".to_string()], + )], + decoded_conditions: Vec::new(), + decoded_actions: vec![], + executable_import_ready: false, + notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_to_runtime_state_import( + &save_slice, + "real-retire-train-missing-scope", + None, + ) + .expect("save slice should project"); + + assert!(import.state.event_runtime_records.is_empty()); + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .and_then(|summary| summary.records[0].import_outcome.as_deref()), + Some("blocked_retire_train_scope") + ); + } + + #[test] + fn blocks_retire_train_without_train_territory_context() { + let base_state = RuntimeState { + companies: vec![crate::RuntimeCompany { + company_id: 42, + controller_kind: RuntimeCompanyControllerKind::Human, + current_cash: 500, + debt: 20, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }], + selected_company_id: Some(42), + trains: vec![RuntimeTrain { + train_id: 1, + owner_company_id: 42, + territory_id: None, + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }], + territories: vec![crate::RuntimeTerritory { + territory_id: 7, + name: Some("Appalachia".to_string()), + track_piece_counts: RuntimeTrackPieceCounts::default(), + }], + ..state() + }; + let save_slice = SmpLoadedSaveSlice { + file_extension_hint: Some("gms".to_string()), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + trailer_family: None, + bridge_family: None, + profile: None, + candidate_availability_table: None, + special_conditions_table: None, + event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary { + source_kind: "packed-event-runtime-collection".to_string(), + mechanism_family: "classic-save-rehydrate-v1".to_string(), + mechanism_confidence: "grounded".to_string(), + container_profile_family: Some("rt3-classic-save-container-v1".to_string()), + metadata_tag_offset: 0x7100, + records_tag_offset: 0x7200, + close_tag_offset: 0x7600, + packed_state_version: 0x3e9, + packed_state_version_hex: "0x000003e9".to_string(), + live_id_bound: 25, + live_record_count: 1, + live_entry_ids: vec![25], + decoded_record_count: 1, + imported_runtime_record_count: 0, + records: vec![crate::SmpLoadedPackedEventRecordSummary { + record_index: 0, + live_entry_id: 25, + payload_offset: Some(0x7202), + payload_len: Some(120), + decode_status: "parity_only".to_string(), + payload_family: "real_packed_v1".to_string(), + trigger_kind: Some(7), + active: None, + marks_collection_dirty: None, + one_shot: Some(false), + compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary { + mode_byte_0x7ef: 7, + primary_selector_0x7f0: 0x63, + grouped_mode_0x7f4: 2, + one_shot_header_0x7f5: 0, + modifier_flag_0x7f9: 1, + modifier_flag_0x7fa: 0, + grouped_target_scope_ordinals_0x7fb: vec![8, 1, 1, 1], + grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0], + summary_toggle_0x800: 1, + grouped_territory_selectors_0x80f: vec![7, -1, -1, -1], + }), + text_bands: packed_text_bands(), + standalone_condition_row_count: 0, + standalone_condition_rows: vec![], + negative_sentinel_scope: None, + grouped_effect_row_counts: vec![1, 0, 0, 0], + grouped_effect_rows: vec![real_retire_train_row(true, Some("Mikado"), vec![])], + decoded_conditions: Vec::new(), + decoded_actions: vec![RuntimeEffect::RetireTrains { + company_target: None, + territory_target: Some(RuntimeTerritoryTarget::Ids { ids: vec![7] }), + locomotive_name: Some("Mikado".to_string()), + }], + executable_import_ready: true, + notes: vec![], + }], + }), + notes: vec![], + }; + + let import = project_save_slice_overlay_to_runtime_state_import( + &base_state, + &save_slice, + "real-retire-train-missing-train-territory", + None, + ) + .expect("overlay import should project"); + + assert_eq!( + import + .state + .packed_event_collection + .as_ref() + .and_then(|summary| summary.records[0].import_outcome.as_deref()), + Some("blocked_missing_train_territory_context") + ); + assert!(import.state.event_runtime_records.is_empty()); + } + #[test] fn keeps_mixed_real_records_out_of_event_runtime_records() { let base_state = RuntimeState { @@ -4352,7 +5533,7 @@ mod tests { .packed_event_collection .as_ref() .and_then(|summary| summary.records[0].import_outcome.as_deref()), - Some("blocked_unmapped_real_descriptor") + Some("blocked_confiscation_variant") ); } @@ -4383,6 +5564,7 @@ mod tests { selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -4557,6 +5739,7 @@ mod tests { selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, + trains: Vec::new(), 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 0537d40..755714c 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -44,7 +44,7 @@ pub use runtime::{ RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimePlayer, RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeTerritory, RuntimeTerritoryMetric, - RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, + RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, RuntimeTrain, RuntimeWorldRestoreState, }; pub use smp::{ diff --git a/crates/rrt-runtime/src/persistence.rs b/crates/rrt-runtime/src/persistence.rs index f5f37de..11c7afb 100644 --- a/crates/rrt-runtime/src/persistence.rs +++ b/crates/rrt-runtime/src/persistence.rs @@ -96,6 +96,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), 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 6d8d8fa..0a6610a 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -83,6 +83,24 @@ pub struct RuntimePlayer { pub controller_kind: RuntimeCompanyControllerKind, } +fn runtime_train_default_active() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeTrain { + pub train_id: u32, + pub owner_company_id: u32, + #[serde(default)] + pub territory_id: Option, + #[serde(default)] + pub locomotive_name: Option, + #[serde(default = "runtime_train_default_active")] + pub active: bool, + #[serde(default)] + pub retired: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RuntimeCompanyTarget { @@ -213,6 +231,9 @@ pub enum RuntimeEffect { key: String, value: bool, }, + SetEconomicStatusCode { + value: i32, + }, SetCompanyCash { target: RuntimeCompanyTarget, value: i64, @@ -221,6 +242,9 @@ pub enum RuntimeEffect { target: RuntimePlayerTarget, value: i64, }, + ConfiscateCompanyAssets { + target: RuntimeCompanyTarget, + }, DeactivateCompany { target: RuntimeCompanyTarget, }, @@ -228,6 +252,14 @@ pub enum RuntimeEffect { target: RuntimeCompanyTarget, value: Option, }, + RetireTrains { + #[serde(default)] + company_target: Option, + #[serde(default)] + territory_target: Option, + #[serde(default)] + locomotive_name: Option, + }, AdjustCompanyCash { target: RuntimeCompanyTarget, delta: i64, @@ -527,6 +559,8 @@ pub struct RuntimeWorldRestoreState { #[serde(default)] pub ai_ignore_territories_at_startup_enabled: Option, #[serde(default)] + pub economic_status_code: Option, + #[serde(default)] pub absolute_counter_restore_kind: Option, #[serde(default)] pub absolute_counter_adjustment_context: Option, @@ -552,6 +586,8 @@ pub struct RuntimeState { #[serde(default)] pub selected_player_id: Option, #[serde(default)] + pub trains: Vec, + #[serde(default)] pub territories: Vec, #[serde(default)] pub company_territory_track_piece_counts: Vec, @@ -639,6 +675,42 @@ impl RuntimeState { } } } + let mut seen_train_ids = BTreeSet::new(); + for train in &self.trains { + if !seen_train_ids.insert(train.train_id) { + return Err(format!("duplicate train_id {}", train.train_id)); + } + if !seen_company_ids.contains(&train.owner_company_id) { + return Err(format!( + "train_id {} references unknown owner_company_id {}", + train.train_id, train.owner_company_id + )); + } + if let Some(territory_id) = train.territory_id { + if !seen_territory_ids.contains(&territory_id) { + return Err(format!( + "train_id {} references unknown territory_id {}", + train.train_id, territory_id + )); + } + } + if train.retired && train.active { + return Err(format!( + "train_id {} cannot be active and retired at the same time", + train.train_id + )); + } + if train + .locomotive_name + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(format!( + "train_id {} has an empty locomotive_name", + train.train_id + )); + } + } for entry in &self.company_territory_track_piece_counts { if !seen_company_ids.contains(&entry.company_id) { return Err(format!( @@ -1009,7 +1081,9 @@ fn validate_runtime_effect( return Err("key must not be empty".to_string()); } } + RuntimeEffect::SetEconomicStatusCode { .. } => {} RuntimeEffect::SetCompanyCash { target, .. } + | RuntimeEffect::ConfiscateCompanyAssets { target } | RuntimeEffect::DeactivateCompany { target } | RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. } | RuntimeEffect::AdjustCompanyCash { target, .. } @@ -1019,6 +1093,30 @@ fn validate_runtime_effect( RuntimeEffect::SetPlayerCash { target, .. } => { validate_player_target(target, valid_player_ids)?; } + RuntimeEffect::RetireTrains { + company_target, + territory_target, + locomotive_name, + } => { + if let Some(company_target) = company_target { + validate_company_target(company_target, valid_company_ids)?; + } + if let Some(territory_target) = territory_target { + validate_territory_target(territory_target, valid_territory_ids)?; + } + if company_target.is_none() && territory_target.is_none() && locomotive_name.is_none() { + return Err( + "retire_trains requires at least one company_target, territory_target, or locomotive_name filter" + .to_string(), + ); + } + if locomotive_name + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err("locomotive_name must not be empty".to_string()); + } + } RuntimeEffect::SetCandidateAvailability { name, .. } => { if name.trim().is_empty() { return Err("name must not be empty".to_string()); @@ -1054,10 +1152,10 @@ fn validate_event_record_template( for (condition_index, condition) in record.conditions.iter().enumerate() { validate_runtime_condition(condition, valid_company_ids, valid_territory_ids).map_err( |err| { - format!( - "template record_id={}.conditions[{condition_index}] {err}", - record.record_id - ) + format!( + "template record_id={}.conditions[{condition_index}] {err}", + record.record_id + ) }, )?; } @@ -1092,9 +1190,7 @@ fn validate_runtime_condition( validate_territory_target(target, valid_territory_ids) } RuntimeCondition::CompanyTerritoryNumericThreshold { - target, - territory, - .. + target, territory, .. } => { validate_company_target(target, valid_company_ids)?; validate_territory_target(territory, valid_territory_ids) @@ -1216,6 +1312,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1255,6 +1352,7 @@ mod tests { disable_train_crashes_enabled: Some(false), disable_train_crashes_and_breakdowns_enabled: Some(false), ai_ignore_territories_at_startup_enabled: Some(false), + economic_status_code: None, absolute_counter_restore_kind: Some( "mode-adjusted-selected-year-lane".to_string(), ), @@ -1267,6 +1365,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1306,6 +1405,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1358,6 +1458,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1410,6 +1511,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: Some(RuntimePackedEventCollectionSummary { @@ -1513,6 +1615,7 @@ mod tests { selected_company_id: Some(2), players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1552,6 +1655,209 @@ mod tests { selected_company_id: Some(1), players: Vec::new(), selected_player_id: None, + trains: Vec::new(), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + assert!(state.validate().is_err()); + } + + #[test] + fn rejects_duplicate_train_ids() { + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 1, + current_cash: 100, + debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + controller_kind: RuntimeCompanyControllerKind::Human, + }], + selected_company_id: None, + players: Vec::new(), + selected_player_id: None, + trains: vec![ + RuntimeTrain { + train_id: 7, + owner_company_id: 1, + territory_id: None, + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 7, + owner_company_id: 1, + territory_id: None, + locomotive_name: Some("Orca".to_string()), + active: true, + retired: false, + }, + ], + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + assert!(state.validate().is_err()); + } + + #[test] + fn rejects_train_with_unknown_owner_company() { + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 1, + current_cash: 100, + debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + controller_kind: RuntimeCompanyControllerKind::Human, + }], + selected_company_id: None, + players: Vec::new(), + selected_player_id: None, + trains: vec![RuntimeTrain { + train_id: 7, + owner_company_id: 2, + territory_id: None, + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }], + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + assert!(state.validate().is_err()); + } + + #[test] + fn rejects_train_with_unknown_territory() { + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 1, + current_cash: 100, + debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + controller_kind: RuntimeCompanyControllerKind::Human, + }], + selected_company_id: None, + players: Vec::new(), + selected_player_id: None, + trains: vec![RuntimeTrain { + train_id: 7, + owner_company_id: 1, + territory_id: Some(9), + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }], + territories: vec![RuntimeTerritory { + territory_id: 1, + name: Some("Appalachia".to_string()), + track_piece_counts: RuntimeTrackPieceCounts::default(), + }], + company_territory_track_piece_counts: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState::default(), + }; + + assert!(state.validate().is_err()); + } + + #[test] + fn rejects_train_marked_active_and_retired() { + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 1, + current_cash: 100, + debt: 0, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + controller_kind: RuntimeCompanyControllerKind::Human, + }], + selected_company_id: None, + players: Vec::new(), + selected_player_id: None, + trains: vec![RuntimeTrain { + train_id: 7, + owner_company_id: 1, + territory_id: None, + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: true, + }], 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 7ceb2f4..aca6caf 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - RuntimeCompanyConditionTestScope, RuntimeCompanyMetric, RuntimeCompanyTarget, - RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, + RuntimeCompanyConditionTestScope, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, + RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, }; @@ -154,14 +154,14 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad label: "Economic Status", target_mask_bits: 0x08, parameter_family: "whole_game_state_enum", - executable_in_runtime: false, + executable_in_runtime: true, }, RealGroupedEffectDescriptorMetadata { descriptor_id: 9, label: "Confiscate All", target_mask_bits: 0x01, parameter_family: "company_confiscation_variant", - executable_in_runtime: false, + executable_in_runtime: true, }, RealGroupedEffectDescriptorMetadata { descriptor_id: 13, @@ -175,7 +175,7 @@ const REAL_GROUPED_EFFECT_DESCRIPTOR_METADATA: [RealGroupedEffectDescriptorMetad label: "Retire Train", target_mask_bits: 0x0d, parameter_family: "company_or_territory_asset_toggle", - executable_in_runtime: false, + executable_in_runtime: true, }, RealGroupedEffectDescriptorMetadata { descriptor_id: 16, @@ -269,7 +269,9 @@ const REAL_ORDINARY_CONDITION_METADATA: [RealOrdinaryConditionMetadata; 22] = [ RealOrdinaryConditionMetadata { raw_condition_id: 2316, label: "Territory Transition Track Pieces", - metric: RealOrdinaryConditionMetric::Territory(RuntimeTerritoryMetric::TrackPiecesTransition), + metric: RealOrdinaryConditionMetric::Territory( + RuntimeTerritoryMetric::TrackPiecesTransition, + ), }, RealOrdinaryConditionMetadata { raw_condition_id: 2317, @@ -279,7 +281,9 @@ const REAL_ORDINARY_CONDITION_METADATA: [RealOrdinaryConditionMetadata; 22] = [ RealOrdinaryConditionMetadata { raw_condition_id: 2318, label: "Territory Non-Electric Track Pieces", - metric: RealOrdinaryConditionMetric::Territory(RuntimeTerritoryMetric::TrackPiecesNonElectric), + metric: RealOrdinaryConditionMetric::Territory( + RuntimeTerritoryMetric::TrackPiecesNonElectric, + ), }, RealOrdinaryConditionMetadata { raw_condition_id: 2323, @@ -2096,14 +2100,36 @@ fn parse_real_event_runtime_record_summary( )?); } } + if let Some(control) = compact_control.as_ref() { + for row in &mut grouped_effect_rows { + if row.descriptor_id != 15 + || row.row_shape != "bool_toggle" + || row.raw_scalar_value == 0 + { + continue; + } + let company_target_present = control + .grouped_target_scope_ordinals_0x7fb + .get(row.group_index) + .copied() + .and_then(real_grouped_company_target) + .is_some(); + let territory_target_present = control + .grouped_territory_selectors_0x80f + .get(row.group_index) + .is_some_and(|selector| *selector >= 0); + if !company_target_present && !territory_target_present { + row.notes + .push("retire train row is missing company and territory scope".to_string()); + } + } + } let negative_sentinel_scope = compact_control.as_ref().and_then(|control| { derive_negative_sentinel_scope_summary(&standalone_condition_rows, control) }); - let decoded_conditions = decode_real_condition_rows( - &standalone_condition_rows, - negative_sentinel_scope.as_ref(), - ); + let decoded_conditions = + decode_real_condition_rows(&standalone_condition_rows, negative_sentinel_scope.as_ref()); let decoded_actions = compact_control .as_ref() .map(|control| decode_real_grouped_effect_actions(&grouped_effect_rows, control)) @@ -2253,7 +2279,10 @@ fn parse_real_condition_row_summary( notes.push("condition row carries candidate-name side string".to_string()); } if ordinary_metadata.is_none() && raw_condition_id >= 0 { - notes.push("ordinary condition id is not yet recovered in the checked-in condition table".to_string()); + notes.push( + "ordinary condition id is not yet recovered in the checked-in condition table" + .to_string(), + ); } Some(SmpLoadedPackedEventConditionRowSummary { row_index, @@ -2462,33 +2491,31 @@ fn decode_real_condition_row( let comparator = decode_real_condition_comparator(row.subtype)?; let value = decode_real_condition_threshold(&row.flag_bytes)?; match metadata.metric { - RealOrdinaryConditionMetric::Company(metric) => Some(RuntimeCondition::CompanyNumericThreshold { - target: RuntimeCompanyTarget::ConditionTrueCompany, - metric, - comparator, - value, - }), - RealOrdinaryConditionMetric::Territory(metric) => { - negative_sentinel_scope - .filter(|scope| scope.territory_scope_selector_is_0x63) - .map(|_| RuntimeCondition::TerritoryNumericThreshold { - target: RuntimeTerritoryTarget::AllTerritories, - metric, - comparator, - value, - }) - } - RealOrdinaryConditionMetric::CompanyTerritory(metric) => { - negative_sentinel_scope - .filter(|scope| scope.territory_scope_selector_is_0x63) - .map(|_| RuntimeCondition::CompanyTerritoryNumericThreshold { - target: RuntimeCompanyTarget::ConditionTrueCompany, - territory: RuntimeTerritoryTarget::AllTerritories, - metric, - comparator, - value, - }) + RealOrdinaryConditionMetric::Company(metric) => { + Some(RuntimeCondition::CompanyNumericThreshold { + target: RuntimeCompanyTarget::ConditionTrueCompany, + metric, + comparator, + value, + }) } + RealOrdinaryConditionMetric::Territory(metric) => negative_sentinel_scope + .filter(|scope| scope.territory_scope_selector_is_0x63) + .map(|_| RuntimeCondition::TerritoryNumericThreshold { + target: RuntimeTerritoryTarget::AllTerritories, + metric, + comparator, + value, + }), + RealOrdinaryConditionMetric::CompanyTerritory(metric) => negative_sentinel_scope + .filter(|scope| scope.territory_scope_selector_is_0x63) + .map(|_| RuntimeCondition::CompanyTerritoryNumericThreshold { + target: RuntimeCompanyTarget::ConditionTrueCompany, + territory: RuntimeTerritoryTarget::AllTerritories, + metric, + comparator, + value, + }), } } @@ -2616,6 +2643,24 @@ fn decode_real_grouped_effect_action( }); } + if descriptor_metadata.executable_in_runtime + && descriptor_metadata.descriptor_id == 8 + && row.row_shape == "scalar_assignment" + { + return Some(RuntimeEffect::SetEconomicStatusCode { + value: row.raw_scalar_value, + }); + } + + if descriptor_metadata.executable_in_runtime + && descriptor_metadata.descriptor_id == 9 + && row.row_shape == "bool_toggle" + && row.raw_scalar_value != 0 + { + let target = real_grouped_company_target(target_scope_ordinal)?; + return Some(RuntimeEffect::ConfiscateCompanyAssets { target }); + } + if descriptor_metadata.executable_in_runtime && descriptor_metadata.descriptor_id == 13 && row.row_shape == "bool_toggle" @@ -2637,6 +2682,30 @@ fn decode_real_grouped_effect_action( }); } + if descriptor_metadata.executable_in_runtime + && descriptor_metadata.descriptor_id == 15 + && row.row_shape == "bool_toggle" + && row.raw_scalar_value != 0 + { + let company_target = real_grouped_company_target(target_scope_ordinal); + let territory_target = compact_control + .grouped_territory_selectors_0x80f + .get(row.group_index) + .copied() + .filter(|selector| *selector >= 0) + .map(|selector| RuntimeTerritoryTarget::Ids { + ids: vec![selector as u32], + }); + if company_target.is_none() && territory_target.is_none() { + return None; + } + return Some(RuntimeEffect::RetireTrains { + company_target, + territory_target, + locomotive_name: row.locomotive_name.clone(), + }); + } + None } @@ -2808,10 +2877,13 @@ fn parse_optional_u16_len_prefixed_string( fn runtime_effect_supported_for_save_import(effect: &RuntimeEffect) -> bool { match effect { RuntimeEffect::SetWorldFlag { .. } + | RuntimeEffect::SetEconomicStatusCode { .. } | RuntimeEffect::SetCandidateAvailability { .. } | RuntimeEffect::SetSpecialCondition { .. } + | RuntimeEffect::ConfiscateCompanyAssets { .. } | RuntimeEffect::DeactivateCompany { .. } | RuntimeEffect::SetCompanyTrackLayingCapacity { .. } + | RuntimeEffect::RetireTrains { .. } | RuntimeEffect::ActivateEventRecord { .. } | RuntimeEffect::DeactivateEventRecord { .. } | RuntimeEffect::RemoveEventRecord { .. } => true, diff --git a/crates/rrt-runtime/src/step.rs b/crates/rrt-runtime/src/step.rs index 828dd83..7c2b3aa 100644 --- a/crates/rrt-runtime/src/step.rs +++ b/crates/rrt-runtime/src/step.rs @@ -6,8 +6,7 @@ use crate::{ RuntimeCompanyControllerKind, RuntimeCompanyMetric, RuntimeCompanyTarget, RuntimeCondition, RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecordTemplate, RuntimePlayerTarget, RuntimeState, RuntimeSummary, RuntimeTerritoryMetric, RuntimeTerritoryTarget, - RuntimeTrackMetric, RuntimeTrackPieceCounts, - calendar::BoundaryEventKind, + RuntimeTrackMetric, RuntimeTrackPieceCounts, calendar::BoundaryEventKind, }; const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4]; @@ -312,6 +311,9 @@ fn apply_runtime_effects( RuntimeEffect::SetWorldFlag { key, value } => { state.world_flags.insert(key.clone(), *value); } + RuntimeEffect::SetEconomicStatusCode { value } => { + state.world_restore.economic_status_code = Some(*value); + } RuntimeEffect::SetCompanyCash { target, value } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { @@ -340,6 +342,28 @@ fn apply_runtime_effects( mutated_player_ids.insert(player_id); } } + RuntimeEffect::ConfiscateCompanyAssets { target } => { + let company_ids = resolve_company_target_ids(state, target, condition_context)?; + for company_id in company_ids.iter().copied() { + let company = state + .companies + .iter_mut() + .find(|company| company.company_id == company_id) + .ok_or_else(|| { + format!( + "missing company_id {company_id} while applying confiscate effect" + ) + })?; + company.current_cash = 0; + company.debt = 0; + company.active = false; + mutated_company_ids.insert(company_id); + if state.selected_company_id == Some(company_id) { + state.selected_company_id = None; + } + } + retire_matching_trains(&mut state.trains, Some(&company_ids), None, None); + } RuntimeEffect::DeactivateCompany { target } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { @@ -375,6 +399,26 @@ fn apply_runtime_effects( mutated_company_ids.insert(company_id); } } + RuntimeEffect::RetireTrains { + company_target, + territory_target, + locomotive_name, + } => { + let company_ids = company_target + .as_ref() + .map(|target| resolve_company_target_ids(state, target, condition_context)) + .transpose()?; + let territory_ids = territory_target + .as_ref() + .map(|target| resolve_territory_target_ids(state, target)) + .transpose()?; + retire_matching_trains( + &mut state.trains, + company_ids.as_ref(), + territory_ids.as_ref(), + locomotive_name.as_deref(), + ); + } RuntimeEffect::AdjustCompanyCash { target, delta } => { let company_ids = resolve_company_target_ids(state, target, condition_context)?; for company_id in company_ids { @@ -523,13 +567,17 @@ fn evaluate_record_conditions( let matching = resolved .into_iter() .filter(|company_id| { - state.companies.iter().find(|company| company.company_id == *company_id).is_some_and( - |company| compare_condition_value( - company_metric_value(company, *metric), - *comparator, - *value, - ), - ) + state + .companies + .iter() + .find(|company| company.company_id == *company_id) + .is_some_and(|company| { + compare_condition_value( + company_metric_value(company, *metric), + *comparator, + *value, + ) + }) }) .collect::>(); if matching.is_empty() { @@ -597,10 +645,7 @@ fn evaluate_record_conditions( })) } -fn intersect_company_matches( - company_matches: &mut Option>, - next: BTreeSet, -) { +fn intersect_company_matches(company_matches: &mut Option>, next: BTreeSet) { match company_matches { Some(existing) => { existing.retain(|company_id| next.contains(company_id)); @@ -790,7 +835,11 @@ fn resolve_player_target_ids( 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()) + Ok(condition_context + .matching_player_ids + .iter() + .copied() + .collect()) } } } @@ -801,9 +850,11 @@ fn resolve_territory_target_ids( target: &RuntimeTerritoryTarget, ) -> Result, String> { match target { - RuntimeTerritoryTarget::AllTerritories => { - Ok(state.territories.iter().map(|territory| territory.territory_id).collect()) - } + RuntimeTerritoryTarget::AllTerritories => Ok(state + .territories + .iter() + .map(|territory| territory.territory_id) + .collect()), RuntimeTerritoryTarget::Ids { ids } => { let known_ids = state .territories @@ -812,7 +863,9 @@ fn resolve_territory_target_ids( .collect::>(); for territory_id in ids { if !known_ids.contains(territory_id) { - return Err(format!("territory target references unknown territory_id {territory_id}")); + return Err(format!( + "territory target references unknown territory_id {territory_id}" + )); } } Ok(ids.clone()) @@ -832,9 +885,7 @@ fn company_metric_value(company: &crate::RuntimeCompany, metric: RuntimeCompanyM RuntimeCompanyMetric::TrackPiecesTransition => { i64::from(company.track_piece_counts.transition) } - RuntimeCompanyMetric::TrackPiecesElectric => { - i64::from(company.track_piece_counts.electric) - } + RuntimeCompanyMetric::TrackPiecesElectric => i64::from(company.track_piece_counts.electric), RuntimeCompanyMetric::TrackPiecesNonElectric => { i64::from(company.track_piece_counts.non_electric) } @@ -846,7 +897,8 @@ fn territory_metric_value( territory_ids: &[u32], metric: RuntimeTerritoryMetric, ) -> i64 { - state.territories + state + .territories .iter() .filter(|territory| territory_ids.contains(&territory.territory_id)) .map(|territory| { @@ -864,9 +916,12 @@ fn company_territory_metric_value( territory_ids: &[u32], metric: RuntimeTrackMetric, ) -> i64 { - state.company_territory_track_piece_counts + state + .company_territory_track_piece_counts .iter() - .filter(|entry| entry.company_id == company_id && territory_ids.contains(&entry.territory_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() } @@ -920,6 +975,34 @@ fn apply_u64_delta(current: u64, delta: i64, company_id: u32) -> Result>, + territory_ids: Option<&Vec>, + locomotive_name: Option<&str>, +) { + for train in trains.iter_mut() { + if !train.active || train.retired { + continue; + } + if company_ids.is_some_and(|company_ids| !company_ids.contains(&train.owner_company_id)) { + continue; + } + if territory_ids.is_some_and(|territory_ids| { + !train + .territory_id + .is_some_and(|territory_id| territory_ids.contains(&territory_id)) + }) { + continue; + } + if locomotive_name.is_some_and(|name| train.locomotive_name.as_deref() != Some(name)) { + continue; + } + train.active = false; + train.retired = true; + } +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -928,7 +1011,8 @@ mod tests { use crate::{ CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeSaveProfileState, - RuntimeServiceState, RuntimeWorldRestoreState, + RuntimeServiceState, RuntimeTerritory, RuntimeTerritoryTarget, RuntimeTrackPieceCounts, + RuntimeTrain, RuntimeWorldRestoreState, }; fn state() -> RuntimeState { @@ -957,6 +1041,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: None, @@ -1927,4 +2012,177 @@ mod tests { assert!(result.is_err()); } + + #[test] + fn applies_economic_status_code_effect() { + let mut state = RuntimeState { + event_runtime_records: vec![RuntimeEventRecord { + record_id: 90, + trigger_kind: 6, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + conditions: Vec::new(), + effects: vec![RuntimeEffect::SetEconomicStatusCode { value: 3 }], + }], + ..state() + }; + + execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 6 }, + ) + .expect("economic-status effect should succeed"); + + assert_eq!(state.world_restore.economic_status_code, Some(3)); + } + + #[test] + fn confiscate_company_assets_zeros_company_and_retires_owned_trains() { + let mut state = RuntimeState { + companies: vec![ + RuntimeCompany { + company_id: 1, + controller_kind: RuntimeCompanyControllerKind::Human, + current_cash: 50, + debt: 7, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }, + RuntimeCompany { + company_id: 2, + controller_kind: RuntimeCompanyControllerKind::Ai, + current_cash: 80, + debt: 9, + credit_rating_score: None, + prime_rate: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + active: true, + available_track_laying_capacity: None, + }, + ], + selected_company_id: Some(1), + trains: vec![ + RuntimeTrain { + train_id: 10, + owner_company_id: 1, + territory_id: None, + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 11, + owner_company_id: 2, + territory_id: None, + locomotive_name: Some("Orca".to_string()), + active: true, + retired: false, + }, + ], + event_runtime_records: vec![RuntimeEventRecord { + record_id: 91, + trigger_kind: 6, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + conditions: Vec::new(), + effects: vec![RuntimeEffect::ConfiscateCompanyAssets { + target: RuntimeCompanyTarget::SelectedCompany, + }], + }], + ..state() + }; + + execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 6 }, + ) + .expect("confiscation effect should succeed"); + + assert_eq!(state.companies[0].current_cash, 0); + assert_eq!(state.companies[0].debt, 0); + assert!(!state.companies[0].active); + assert_eq!(state.selected_company_id, None); + assert!(state.trains[0].retired); + assert!(!state.trains[1].retired); + } + + #[test] + fn retire_trains_respects_company_territory_and_locomotive_filters() { + let mut state = RuntimeState { + territories: vec![ + RuntimeTerritory { + territory_id: 7, + name: Some("Appalachia".to_string()), + track_piece_counts: RuntimeTrackPieceCounts::default(), + }, + RuntimeTerritory { + territory_id: 8, + name: Some("Great Plains".to_string()), + track_piece_counts: RuntimeTrackPieceCounts::default(), + }, + ], + trains: vec![ + RuntimeTrain { + train_id: 10, + owner_company_id: 1, + territory_id: Some(7), + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 11, + owner_company_id: 1, + territory_id: Some(7), + locomotive_name: Some("Orca".to_string()), + active: true, + retired: false, + }, + RuntimeTrain { + train_id: 12, + owner_company_id: 1, + territory_id: Some(8), + locomotive_name: Some("Mikado".to_string()), + active: true, + retired: false, + }, + ], + event_runtime_records: vec![RuntimeEventRecord { + record_id: 92, + trigger_kind: 6, + active: true, + service_count: 0, + marks_collection_dirty: false, + one_shot: false, + has_fired: false, + conditions: Vec::new(), + effects: vec![RuntimeEffect::RetireTrains { + company_target: Some(RuntimeCompanyTarget::SelectedCompany), + territory_target: Some(RuntimeTerritoryTarget::Ids { ids: vec![7] }), + locomotive_name: Some("Mikado".to_string()), + }], + }], + selected_company_id: Some(1), + ..state() + }; + + execute_step_command( + &mut state, + &StepCommand::ServiceTriggerKind { trigger_kind: 6 }, + ) + .expect("retire-trains effect should succeed"); + + assert!(state.trains[0].retired); + assert!(!state.trains[1].retired); + assert!(!state.trains[2].retired); + } } diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index 9632693..f724804 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -24,12 +24,16 @@ pub struct RuntimeSummary { pub world_restore_disable_train_crashes_enabled: Option, pub world_restore_disable_train_crashes_and_breakdowns_enabled: Option, pub world_restore_ai_ignore_territories_at_startup_enabled: Option, + pub world_restore_economic_status_code: Option, pub world_restore_absolute_counter_restore_kind: Option, pub world_restore_absolute_counter_adjustment_context: Option, pub metadata_count: usize, pub company_count: usize, pub active_company_count: usize, pub player_count: usize, + pub train_count: usize, + pub active_train_count: usize, + pub retired_train_count: usize, pub territory_count: usize, pub company_territory_track_count: usize, pub packed_event_collection_present: bool, @@ -55,6 +59,11 @@ pub struct RuntimeSummary { 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_missing_train_context_count: usize, + pub packed_event_blocked_missing_train_territory_context_count: usize, + pub packed_event_blocked_confiscation_variant_count: usize, + pub packed_event_blocked_retire_train_variant_count: usize, + pub packed_event_blocked_retire_train_scope_count: usize, pub packed_event_blocked_structural_only_count: usize, pub event_runtime_record_count: usize, pub candidate_availability_count: usize, @@ -127,6 +136,7 @@ impl RuntimeSummary { world_restore_ai_ignore_territories_at_startup_enabled: state .world_restore .ai_ignore_territories_at_startup_enabled, + world_restore_economic_status_code: state.world_restore.economic_status_code, world_restore_absolute_counter_restore_kind: state .world_restore .absolute_counter_restore_kind @@ -143,6 +153,9 @@ impl RuntimeSummary { .filter(|company| company.active) .count(), player_count: state.players.len(), + train_count: state.trains.len(), + active_train_count: state.trains.iter().filter(|train| train.active).count(), + retired_train_count: state.trains.iter().filter(|train| train.retired).count(), territory_count: state.territories.len(), company_territory_track_count: state.company_territory_track_piece_counts.len(), packed_event_collection_present: state.packed_event_collection.is_some(), @@ -421,6 +434,73 @@ impl RuntimeSummary { .count() }) .unwrap_or(0), + packed_event_blocked_missing_train_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_train_context") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_missing_train_territory_context_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() + == Some("blocked_missing_train_territory_context") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_confiscation_variant_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() == Some("blocked_confiscation_variant") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_retire_train_variant_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() == Some("blocked_retire_train_variant") + }) + .count() + }) + .unwrap_or(0), + packed_event_blocked_retire_train_scope_count: state + .packed_event_collection + .as_ref() + .map(|summary| { + summary + .records + .iter() + .filter(|record| { + record.import_outcome.as_deref() == Some("blocked_retire_train_scope") + }) + .count() + }) + .unwrap_or(0), packed_event_blocked_structural_only_count: state .packed_event_collection .as_ref() @@ -481,8 +561,8 @@ mod tests { use crate::{ CalendarPoint, RuntimeCompany, RuntimeCompanyControllerKind, RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary, - RuntimeTrackPieceCounts, - RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState, + RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, RuntimeTrackPieceCounts, + RuntimeWorldRestoreState, }; use super::RuntimeSummary; @@ -504,6 +584,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), packed_event_collection: Some(RuntimePackedEventCollectionSummary { @@ -731,6 +812,7 @@ mod tests { selected_company_id: None, players: Vec::new(), selected_player_id: None, + trains: Vec::new(), 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 7202070..f22a3f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -91,6 +91,9 @@ The highest-value next passes are now: track - exact named-territory binding now executes too, while named-territory no-match cases remain the explicit binding blocker frontier +- real descriptors `8` `Economic Status`, `9` `Confiscate All`, and `15` `Retire Train` now join + the executable batch through the same ordinary runtime path, backed by the opaque economic-status + lane and the minimal event-owned train roster - 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, diff --git a/docs/runtime-rehost-plan.md b/docs/runtime-rehost-plan.md index 6b049e7..042239e 100644 --- a/docs/runtime-rehost-plan.md +++ b/docs/runtime-rehost-plan.md @@ -40,13 +40,17 @@ Implemented today: - 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 +- a minimal event-owned train surface and an opaque economic-status lane now exist in runtime + state, and real descriptors `8` = `Economic Status`, `9` = `Confiscate All`, and `15` = + `Retire Train` now import and execute through the ordinary runtime path when overlay context + supplies the required train ownership data +- descriptor `3` = `Territory - Allow All` remains the explicit parity-only descriptor frontier + 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, 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. +broader real policy-descriptor coverage beyond `3/8/9/15`, wider ordinary condition-id coverage +beyond the current numeric-threshold batch, and richer train/runtime simulation only if later +descriptor families need more than the current event-owned roster. ## Why This Boundary diff --git a/fixtures/runtime/packed-event-confiscate-all-false-save-slice-fixture.json b/fixtures/runtime/packed-event-confiscate-all-false-save-slice-fixture.json new file mode 100644 index 0000000..6f97fe4 --- /dev/null +++ b/fixtures/runtime/packed-event-confiscate-all-false-save-slice-fixture.json @@ -0,0 +1,32 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-confiscate-all-false-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture keeping the unsupported FALSE Confiscate All variant explicit." + }, + "state_save_slice_path": "packed-event-confiscate-all-false-save-slice.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 0, + "event_runtime_record_count": 0, + "packed_event_blocked_confiscation_variant_count": 1 + }, + "expected_state_fragment": { + "packed_event_collection": { + "records": [ + { + "import_outcome": "blocked_confiscation_variant" + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-confiscate-all-false-save-slice.json b/fixtures/runtime/packed-event-confiscate-all-false-save-slice.json new file mode 100644 index 0000000..e7a1da8 --- /dev/null +++ b/fixtures/runtime/packed-event-confiscate-all-false-save-slice.json @@ -0,0 +1,101 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-confiscate-all-false-save-slice", + "source": { + "description": "Tracked save-slice document with an unsupported FALSE Confiscate All row.", + "original_save_filename": "captured-confiscate-all-false.gms", + "original_save_sha256": "confiscate-all-false-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "keeps the unsupported descriptor 9 variant explicit" + ] + }, + "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": 20, + "live_record_count": 1, + "live_entry_ids": [20], + "decoded_record_count": 1, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 20, + "payload_offset": 29280, + "payload_len": 120, + "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": 1, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 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": 9, + "descriptor_label": "Confiscate All", + "target_mask_bits": 1, + "parameter_family": "company_confiscation_variant", + "opcode": 1, + "raw_scalar_value": 0, + "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 Confiscate All to FALSE", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [], + "decoded_actions": [], + "executable_import_ready": false, + "notes": [ + "decoded from grounded real 0x4e9a row framing" + ] + } + ] + }, + "notes": [ + "unsupported real confiscate-all FALSE variant sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-confiscate-all-overlay-fixture.json b/fixtures/runtime/packed-event-confiscate-all-overlay-fixture.json new file mode 100644 index 0000000..0e6d0c9 --- /dev/null +++ b/fixtures/runtime/packed-event-confiscate-all-overlay-fixture.json @@ -0,0 +1,81 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-confiscate-all-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving descriptor 9 Confiscate All imports and executes through the ordinary runtime path." + }, + "state_import_path": "packed-event-confiscate-all-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": 2, + "active_company_count": 1, + "train_count": 3, + "active_train_count": 1, + "retired_train_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, + "total_company_cash": 90 + }, + "expected_state_fragment": { + "selected_company_id": null, + "companies": [ + { + "company_id": 1, + "current_cash": 0, + "debt": 0, + "active": false + }, + { + "company_id": 2, + "current_cash": 90, + "debt": 40, + "active": true + } + ], + "trains": [ + { + "train_id": 100, + "active": false, + "retired": true + }, + { + "train_id": 101, + "active": false, + "retired": true + }, + { + "train_id": 102, + "active": true, + "retired": false + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_actions": [ + { + "kind": "confiscate_company_assets", + "target": { + "kind": "selected_company" + } + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-confiscate-all-overlay.json b/fixtures/runtime/packed-event-confiscate-all-overlay.json new file mode 100644 index 0000000..5ae6822 --- /dev/null +++ b/fixtures/runtime/packed-event-confiscate-all-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-confiscate-all-overlay", + "source": { + "description": "Overlay import combining world/train runtime context with the real Confiscate All descriptor sample." + }, + "base_snapshot_path": "packed-event-world-train-overlay-base-snapshot.json", + "save_slice_path": "packed-event-confiscate-all-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-confiscate-all-save-slice.json b/fixtures/runtime/packed-event-confiscate-all-save-slice.json new file mode 100644 index 0000000..e186de3 --- /dev/null +++ b/fixtures/runtime/packed-event-confiscate-all-save-slice.json @@ -0,0 +1,108 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-confiscate-all-save-slice", + "source": { + "description": "Tracked save-slice document with a real Confiscate All row.", + "original_save_filename": "captured-confiscate-all.gms", + "original_save_sha256": "confiscate-all-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves descriptor 9 import into company liquidation plus owned-train retirement" + ] + }, + "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": 19, + "live_record_count": 1, + "live_entry_ids": [19], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 19, + "payload_offset": 29280, + "payload_len": 120, + "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": 1, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 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": 9, + "descriptor_label": "Confiscate All", + "target_mask_bits": 1, + "parameter_family": "company_confiscation_variant", + "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 Confiscate All to TRUE", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [], + "decoded_actions": [ + { + "kind": "confiscate_company_assets", + "target": { + "kind": "selected_company" + } + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing" + ] + } + ] + }, + "notes": [ + "real confiscate-all descriptor sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-economic-status-overlay-fixture.json b/fixtures/runtime/packed-event-economic-status-overlay-fixture.json new file mode 100644 index 0000000..a2ebc04 --- /dev/null +++ b/fixtures/runtime/packed-event-economic-status-overlay-fixture.json @@ -0,0 +1,48 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-economic-status-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving descriptor 8 Economic Status imports and executes through the ordinary runtime path." + }, + "state_import_path": "packed-event-economic-status-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": 2, + "train_count": 3, + "territory_count": 2, + "world_restore_economic_status_code": 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": { + "world_restore": { + "economic_status_code": 2 + }, + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_actions": [ + { + "kind": "set_economic_status_code", + "value": 2 + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-economic-status-overlay.json b/fixtures/runtime/packed-event-economic-status-overlay.json new file mode 100644 index 0000000..1468402 --- /dev/null +++ b/fixtures/runtime/packed-event-economic-status-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-economic-status-overlay", + "source": { + "description": "Overlay import combining world/train runtime context with the real Economic Status descriptor sample." + }, + "base_snapshot_path": "packed-event-world-train-overlay-base-snapshot.json", + "save_slice_path": "packed-event-economic-status-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-economic-status-save-slice.json b/fixtures/runtime/packed-event-economic-status-save-slice.json new file mode 100644 index 0000000..0ba258b --- /dev/null +++ b/fixtures/runtime/packed-event-economic-status-save-slice.json @@ -0,0 +1,106 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-economic-status-save-slice", + "source": { + "description": "Tracked save-slice document with a real Economic Status row.", + "original_save_filename": "captured-economic-status.gms", + "original_save_sha256": "economic-status-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves descriptor 8 import into the opaque economic-status runtime lane" + ] + }, + "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": 18, + "live_record_count": 1, + "live_entry_ids": [18], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 18, + "payload_offset": 29280, + "payload_len": 120, + "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": 1, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 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": 8, + "descriptor_label": "Economic Status", + "target_mask_bits": 8, + "parameter_family": "whole_game_state_enum", + "opcode": 3, + "raw_scalar_value": 2, + "value_byte_0x09": 0, + "value_dword_0x0d": 0, + "value_byte_0x11": 0, + "value_byte_0x12": 0, + "value_word_0x14": 0, + "value_word_0x16": 0, + "row_shape": "scalar_assignment", + "semantic_family": "scalar_assignment", + "semantic_preview": "Set Economic Status to 2", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [], + "decoded_actions": [ + { + "kind": "set_economic_status_code", + "value": 2 + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing" + ] + } + ] + }, + "notes": [ + "real economic status descriptor sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-mixed-company-descriptor-overlay-fixture.json b/fixtures/runtime/packed-event-mixed-company-descriptor-overlay-fixture.json index c41b1ef..aeb6ed0 100644 --- a/fixtures/runtime/packed-event-mixed-company-descriptor-overlay-fixture.json +++ b/fixtures/runtime/packed-event-mixed-company-descriptor-overlay-fixture.json @@ -21,7 +21,7 @@ "packed_event_record_count": 1, "packed_event_decoded_record_count": 1, "packed_event_imported_runtime_record_count": 0, - "packed_event_blocked_unmapped_real_descriptor_count": 1, + "packed_event_blocked_confiscation_variant_count": 1, "event_runtime_record_count": 0, "total_event_record_service_count": 0, "total_trigger_dispatch_count": 1 @@ -45,7 +45,7 @@ "packed_event_collection": { "records": [ { - "import_outcome": "blocked_unmapped_real_descriptor" + "import_outcome": "blocked_confiscation_variant" } ] }, diff --git a/fixtures/runtime/packed-event-mixed-company-descriptor-save-slice.json b/fixtures/runtime/packed-event-mixed-company-descriptor-save-slice.json index 76e4c9a..4e2669c 100644 --- a/fixtures/runtime/packed-event-mixed-company-descriptor-save-slice.json +++ b/fixtures/runtime/packed-event-mixed-company-descriptor-save-slice.json @@ -86,21 +86,21 @@ { "group_index": 1, "row_index": 0, - "descriptor_id": 8, - "descriptor_label": "Economic Status", - "target_mask_bits": 8, - "parameter_family": "whole_game_state_enum", - "opcode": 3, - "raw_scalar_value": 2, + "descriptor_id": 9, + "descriptor_label": "Confiscate All", + "target_mask_bits": 1, + "parameter_family": "company_confiscation_variant", + "opcode": 1, + "raw_scalar_value": 0, "value_byte_0x09": 0, "value_dword_0x0d": 0, "value_byte_0x11": 0, "value_byte_0x12": 0, "value_word_0x14": 0, "value_word_0x16": 0, - "row_shape": "scalar_assignment", - "semantic_family": "scalar_assignment", - "semantic_preview": "Set Economic Status to 2", + "row_shape": "bool_toggle", + "semantic_family": "bool_toggle", + "semantic_preview": "Set Confiscate All to FALSE", "locomotive_name": null, "notes": [] } diff --git a/fixtures/runtime/packed-event-retire-train-company-overlay-fixture.json b/fixtures/runtime/packed-event-retire-train-company-overlay-fixture.json new file mode 100644 index 0000000..8ca57e9 --- /dev/null +++ b/fixtures/runtime/packed-event-retire-train-company-overlay-fixture.json @@ -0,0 +1,64 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-retire-train-company-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving descriptor 15 Retire Train executes against company scope." + }, + "state_import_path": "packed-event-retire-train-company-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": 2, + "train_count": 3, + "active_train_count": 1, + "retired_train_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": { + "trains": [ + { + "train_id": 100, + "active": false, + "retired": true + }, + { + "train_id": 101, + "active": false, + "retired": true + }, + { + "train_id": 102, + "active": true, + "retired": false + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_actions": [ + { + "kind": "retire_trains", + "company_target": { + "kind": "selected_company" + } + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-retire-train-company-overlay.json b/fixtures/runtime/packed-event-retire-train-company-overlay.json new file mode 100644 index 0000000..28e6138 --- /dev/null +++ b/fixtures/runtime/packed-event-retire-train-company-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-retire-train-company-overlay", + "source": { + "description": "Overlay import combining world/train runtime context with the company-scoped Retire Train descriptor sample." + }, + "base_snapshot_path": "packed-event-world-train-overlay-base-snapshot.json", + "save_slice_path": "packed-event-retire-train-company-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-retire-train-company-save-slice.json b/fixtures/runtime/packed-event-retire-train-company-save-slice.json new file mode 100644 index 0000000..ba4cdda --- /dev/null +++ b/fixtures/runtime/packed-event-retire-train-company-save-slice.json @@ -0,0 +1,108 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-retire-train-company-save-slice", + "source": { + "description": "Tracked save-slice document with a real company-scoped Retire Train row.", + "original_save_filename": "captured-retire-train-company.gms", + "original_save_sha256": "retire-train-company-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves descriptor 15 import through company scope" + ] + }, + "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": 21, + "live_record_count": 1, + "live_entry_ids": [21], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 21, + "payload_offset": 29280, + "payload_len": 120, + "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": 1, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [1, 1, 1, 1], + "grouped_scope_checkboxes_0x7ff": [1, 0, 0, 0], + "summary_toggle_0x800": 1, + "grouped_territory_selectors_0x80f": [-1, -1, -1, -1] + }, + "text_bands": [], + "standalone_condition_row_count": 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": 15, + "descriptor_label": "Retire Train", + "target_mask_bits": 13, + "parameter_family": "company_or_territory_asset_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 Retire Train to TRUE", + "locomotive_name": null, + "notes": [] + } + ], + "decoded_conditions": [], + "decoded_actions": [ + { + "kind": "retire_trains", + "company_target": { + "kind": "selected_company" + } + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing" + ] + } + ] + }, + "notes": [ + "real retire-train descriptor sample scoped by company" + ] + } +} diff --git a/fixtures/runtime/packed-event-retire-train-missing-scope-save-slice-fixture.json b/fixtures/runtime/packed-event-retire-train-missing-scope-save-slice-fixture.json new file mode 100644 index 0000000..af5e889 --- /dev/null +++ b/fixtures/runtime/packed-event-retire-train-missing-scope-save-slice-fixture.json @@ -0,0 +1,32 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-retire-train-missing-scope-save-slice-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture keeping the missing-scope Retire Train variant explicit." + }, + "state_save_slice_path": "packed-event-retire-train-missing-scope-save-slice.json", + "commands": [ + { + "kind": "service_trigger_kind", + "trigger_kind": 7 + } + ], + "expected_summary": { + "packed_event_collection_present": true, + "packed_event_record_count": 1, + "packed_event_decoded_record_count": 1, + "packed_event_imported_runtime_record_count": 0, + "event_runtime_record_count": 0, + "packed_event_blocked_retire_train_scope_count": 1 + }, + "expected_state_fragment": { + "packed_event_collection": { + "records": [ + { + "import_outcome": "blocked_retire_train_scope" + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-retire-train-missing-scope-save-slice.json b/fixtures/runtime/packed-event-retire-train-missing-scope-save-slice.json new file mode 100644 index 0000000..fd2390d --- /dev/null +++ b/fixtures/runtime/packed-event-retire-train-missing-scope-save-slice.json @@ -0,0 +1,103 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-retire-train-missing-scope-save-slice", + "source": { + "description": "Tracked save-slice document with a Retire Train row missing both company and territory scope.", + "original_save_filename": "captured-retire-train-missing-scope.gms", + "original_save_sha256": "retire-train-missing-scope-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "keeps the missing-scope descriptor 15 variant explicit" + ] + }, + "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": 23, + "live_record_count": 1, + "live_entry_ids": [23], + "decoded_record_count": 1, + "imported_runtime_record_count": 0, + "records": [ + { + "record_index": 0, + "live_entry_id": 23, + "payload_offset": 29280, + "payload_len": 120, + "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": 1, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [8, 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": 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": 15, + "descriptor_label": "Retire Train", + "target_mask_bits": 13, + "parameter_family": "company_or_territory_asset_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 Retire Train to TRUE", + "locomotive_name": "Mikado", + "notes": [ + "retire train row is missing company and territory scope" + ] + } + ], + "decoded_conditions": [], + "decoded_actions": [], + "executable_import_ready": false, + "notes": [ + "decoded from grounded real 0x4e9a row framing" + ] + } + ] + }, + "notes": [ + "unsupported real retire-train missing-scope sample" + ] + } +} diff --git a/fixtures/runtime/packed-event-retire-train-territory-overlay-fixture.json b/fixtures/runtime/packed-event-retire-train-territory-overlay-fixture.json new file mode 100644 index 0000000..df0ab8b --- /dev/null +++ b/fixtures/runtime/packed-event-retire-train-territory-overlay-fixture.json @@ -0,0 +1,66 @@ +{ + "format_version": 1, + "fixture_id": "packed-event-retire-train-territory-overlay-fixture", + "source": { + "kind": "captured-runtime", + "description": "Fixture proving descriptor 15 Retire Train executes against territory and locomotive filters." + }, + "state_import_path": "packed-event-retire-train-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": 2, + "train_count": 3, + "active_train_count": 1, + "retired_train_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": { + "trains": [ + { + "train_id": 100, + "active": false, + "retired": true + }, + { + "train_id": 101, + "active": true, + "retired": false + }, + { + "train_id": 102, + "active": false, + "retired": true + } + ], + "packed_event_collection": { + "records": [ + { + "import_outcome": "imported", + "decoded_actions": [ + { + "kind": "retire_trains", + "territory_target": { + "kind": "ids", + "ids": [7] + }, + "locomotive_name": "Mikado" + } + ] + } + ] + } + } +} diff --git a/fixtures/runtime/packed-event-retire-train-territory-overlay.json b/fixtures/runtime/packed-event-retire-train-territory-overlay.json new file mode 100644 index 0000000..4b4bb0e --- /dev/null +++ b/fixtures/runtime/packed-event-retire-train-territory-overlay.json @@ -0,0 +1,9 @@ +{ + "format_version": 1, + "import_id": "packed-event-retire-train-territory-overlay", + "source": { + "description": "Overlay import combining world/train runtime context with the territory-scoped Retire Train descriptor sample." + }, + "base_snapshot_path": "packed-event-world-train-overlay-base-snapshot.json", + "save_slice_path": "packed-event-retire-train-territory-save-slice.json" +} diff --git a/fixtures/runtime/packed-event-retire-train-territory-save-slice.json b/fixtures/runtime/packed-event-retire-train-territory-save-slice.json new file mode 100644 index 0000000..424838b --- /dev/null +++ b/fixtures/runtime/packed-event-retire-train-territory-save-slice.json @@ -0,0 +1,112 @@ +{ + "format_version": 1, + "save_slice_id": "packed-event-retire-train-territory-save-slice", + "source": { + "description": "Tracked save-slice document with a real territory- and locomotive-scoped Retire Train row.", + "original_save_filename": "captured-retire-train-territory.gms", + "original_save_sha256": "retire-train-territory-sample-sha256", + "notes": [ + "tracked as JSON save-slice document rather than raw .smp", + "proves descriptor 15 import through territory selector plus locomotive-name filtering" + ] + }, + "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": 22, + "live_record_count": 1, + "live_entry_ids": [22], + "decoded_record_count": 1, + "imported_runtime_record_count": 1, + "records": [ + { + "record_index": 0, + "live_entry_id": 22, + "payload_offset": 29280, + "payload_len": 120, + "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": 1, + "modifier_flag_0x7fa": 0, + "grouped_target_scope_ordinals_0x7fb": [8, 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": 15, + "descriptor_label": "Retire Train", + "target_mask_bits": 13, + "parameter_family": "company_or_territory_asset_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 Retire Train to TRUE", + "locomotive_name": "Mikado", + "notes": [ + "grouped effect row carries locomotive-name side string" + ] + } + ], + "decoded_conditions": [], + "decoded_actions": [ + { + "kind": "retire_trains", + "territory_target": { + "kind": "ids", + "ids": [7] + }, + "locomotive_name": "Mikado" + } + ], + "executable_import_ready": true, + "notes": [ + "decoded from grounded real 0x4e9a row framing" + ] + } + ] + }, + "notes": [ + "real retire-train descriptor sample scoped by territory and locomotive name" + ] + } +} diff --git a/fixtures/runtime/packed-event-world-train-overlay-base-snapshot.json b/fixtures/runtime/packed-event-world-train-overlay-base-snapshot.json new file mode 100644 index 0000000..f269b39 --- /dev/null +++ b/fixtures/runtime/packed-event-world-train-overlay-base-snapshot.json @@ -0,0 +1,81 @@ +{ + "format_version": 1, + "snapshot_id": "packed-event-world-train-overlay-base-snapshot", + "source": { + "description": "Base runtime snapshot supplying company, territory, and train context for real descriptor 8/9/15 overlays." + }, + "state": { + "calendar": { + "year": 1845, + "month_slot": 2, + "phase_slot": 1, + "tick_slot": 3 + }, + "world_flags": { + "base.only": true + }, + "metadata": { + "base.note": "world-and-train overlay context" + }, + "companies": [ + { + "company_id": 1, + "current_cash": 150, + "debt": 80, + "credit_rating_score": 650, + "prime_rate": 5, + "controller_kind": "human" + }, + { + "company_id": 2, + "current_cash": 90, + "debt": 40, + "credit_rating_score": 480, + "prime_rate": 6, + "controller_kind": "ai" + } + ], + "selected_company_id": 1, + "players": [], + "trains": [ + { + "train_id": 100, + "owner_company_id": 1, + "territory_id": 7, + "locomotive_name": "Mikado" + }, + { + "train_id": 101, + "owner_company_id": 1, + "territory_id": 8, + "locomotive_name": "Orca" + }, + { + "train_id": 102, + "owner_company_id": 2, + "territory_id": 7, + "locomotive_name": "Mikado" + } + ], + "territories": [ + { + "territory_id": 7, + "name": "Appalachia" + }, + { + "territory_id": 8, + "name": "Great Plains" + } + ], + "company_territory_track_piece_counts": [], + "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 + } + } +}